Implement Thinkloom desktop MVP

This commit is contained in:
2026-07-17 00:41:22 -07:00
parent 26e5808d66
commit b5e6eafa1c
30 changed files with 12805 additions and 274 deletions
+6
View File
@@ -40,3 +40,9 @@ next-env.d.ts
/.wrangler/
/outputs/
/work/
# Thinkloom generated artifacts
/desktop-dist/
/src-tauri/target/
*.tsbuildinfo
+32
View File
@@ -0,0 +1,32 @@
# Thinkloom implementation status
Status date: 2026-07-16
## Completed locally
- M0 engineering baseline: shared React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, browser/Sites companion, build/type/lint/unit checks.
- M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, canonical rebuild inputs.
- M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; retry-preserving failure state.
- M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, drafting sets.
- M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, autosave.
- M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline and contribution relationships.
- M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, strict ZIP import validation.
## External release gates
These items cannot be honestly certified from this workspace alone and need project-owner decisions or assets:
1. Voice runtime: provide/approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral browser speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present.
2. Local-model acceptance baseline: confirm the Ollama model used for release testing. The implementation default is `llama3.2`.
3. Signing and distribution: provide the Windows code-signing certificate and choose the installer distribution channel. MSI/NSIS configuration is present, but signed release artifacts cannot be produced without credentials.
4. Secondary platforms: decide whether macOS and Linux are part of the first signed release. Their keychain, microphone, installer, and packaging gates require platform runners.
5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a browser-print engine for richer typography.
6. Diagnostics policy: confirm whether opt-in diagnostic reports are allowed. Current diagnostics are local, redacted, and exclude prompts, responses, credentials, and audio.
## Remaining hardening
- Run microphone/VAD/transcription integration tests once the approved model assets are supplied.
- Run signed clean-install, upgrade, downgrade-warning, and uninstall tests on each release platform.
- Run full 20,000-word interaction and fault-injection profiling on packaged release hardware.
- Resolve the upstream moderate PostCSS advisory inherited by the pinned Next/Vinext preview toolchain when a compatible patch is available. Native packaged assets do not execute the Sites server dependency.
- Deploy the Sites companion after a deployment credential is issued by the connected Sites service.
+58 -98
View File
@@ -1,98 +1,58 @@
# vinext-starter
A clean full-stack starter running on
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
## Prerequisites
- Node.js `>=22.13.0`
## Quick Start
```bash
npm install
npm run dev
npm run build
```
This starter does not use `wrangler.jsonc`.
## Included Shape
- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers
OpenAI workspace sites can read the current user's email from
`oai-authenticated-user-email`.
SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent:
```tsx
import { headers } from "next/headers";
export default async function Home() {
const requestHeaders = await headers();
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email;
// ...
}
```
## Optional Dispatch-Owned ChatGPT Sign-In
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands
- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
## Learn More
- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
# Thinkloom
Thinkloom is a desktop-first, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record.
This repository contains two builds of the same React interface:
- a Tauri 2 desktop application backed by Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports;
- a Sites companion build for product preview and browser-based evaluation.
## Implemented workflow
- Reversible Ideation, Drafting, and Finalization phases
- Typed conversation, challenge levels, push-to-talk browser transcription, and optional visible speech output
- Suggested ideas with explicit accept/reject, editing, variants, archiving, source links, drafting sets, and merges
- TipTap/ProseMirror structured manuscript editor with canonical Markdown round-tripping, undo/redo, headings, lists, selection replacement, and cursor insertion
- Persisted preview-first generation states with retry-safe provider errors and partial acceptance
- Ollama, OpenAI, and OpenAI-compatible provider profiles; credentials use the operating-system vault
- Local/Cloud/Mixed status and project-scoped cloud approval
- SQLite live state, atomic canonical files, seven rotating recovery snapshots, and a hidden Git repository per project
- Append-only SHA-256 provenance journal with chain-head verification and a contribution relationship view
- Named versions, restore controls, release checkpoints, and tags using non-Git language in the UI
- Markdown, HTML, PDF, plain text, sanitized evidence ZIP, and complete project backup ZIP generation
- ZIP import path, symlink, file-count, and expanded-size validation
- Responsive, keyboard-navigable, screen-reader-labeled UI with reduced-motion and dark-mode support
## Development
Requirements: Node.js 22.13 or newer, Rust 1.77.2 or newer, Git, and the Windows WebView2 runtime for the primary desktop target.
```powershell
npm install
npm run dev
npm run desktop:dev
npm run build
npm run desktop:build
npm run tauri -- build
```
Quality checks:
```powershell
npm run typecheck
npm run lint
npm test
cd src-tauri
cargo fmt --check
cargo test
```
## Project storage
A desktop project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the projects hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
## Provider setup
Ollama defaults to `http://127.0.0.1:11434` and model `llama3.2`. OpenAI and compatible credentials are entered in Settings and saved through Windows Credential Manager, macOS Keychain, or Linux Secret Service. The first cloud operation in each project requires explicit approval.
See [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) for release gates that require external models, signing credentials, or additional platform validation.
+9 -26
View File
File diff suppressed because one or more lines are too long
+16 -34
View File
@@ -1,38 +1,20 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Writing Workspace Framework",
description: "A conversation-first writing workspace visualization.",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const image = new URL("/og.png", `${protocol}://${host}`).toString();
return {
title: "Thinkloom — ideas into writing",
description: "A local-first writing studio for exploring ideas, shaping drafts, and preserving your creative process.",
icons: { icon: "/icon.png", shortcut: "/icon.png", apple: "/icon.png" },
openGraph: { title: "Thinkloom — ideas into writing", description: "Explore, shape, and publish thoughtful writing without losing the thread of how it came together.", type: "website", images: [{ url: image, width: 1732, height: 909, alt: "Thinkloom — ideas into writing, without losing the thread." }] },
twitter: { card: "summary_large_image", images: [image] },
};
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="en"><body>{children}</body></html>; }
+2 -5
View File
@@ -1,5 +1,2 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/writing-workspace-framework.html");
}
import Thinkloom from "./thinkloom";
export default function Home() { return <Thinkloom />; }
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
type Phase = "ideation" | "drafting" | "finalization";
type View = Phase | "project" | "history" | "provenance" | "export" | "settings";
type IdeaStatus = "suggested" | "accepted" | "rejected" | "archived";
type Actor = "user" | "assistant" | "system";
interface Turn { id: string; speaker: "user" | "assistant"; text: string; createdAt: string }
interface Idea { id: string; title: string; summary: string; detail: string; status: IdeaStatus; sourceTurnIds: string[]; parentIdeaIds: string[]; tags: string[]; pinned: boolean; selected: boolean; group?: string; createdBy: "user" | "assistant" | "mixed" }
interface Event { id: string; type: string; actor: Actor; at: string; summary: string; previousHash: string | null; hash: string; provider?: string }
interface Checkpoint { id: string; name: string; at: string; manuscript: string; words: number }
interface Provider { kind: "ollama" | "openai" | "compatible"; name: string; endpoint: string; model: string; mode: "local" | "cloud"; connected: boolean }
interface State { id: string; title: string; subtitle: string; phase: Phase; privacy: "Local" | "Cloud" | "Mixed"; cloudApproved: boolean; challenge: "Gentle" | "Balanced" | "Rigorous"; spokenReplies: boolean; turns: Turn[]; ideas: Idea[]; manuscript: string; generation: { id: string; state: "idle" | "streaming" | "staged" | "accepted" | "rejected" | "failed"; text: string; prompt: string; relation: string }; events: Event[]; checkpoints: Checkpoint[]; provider: Provider; styleTraits: string[]; disallowedHabits: string[]; finalized: boolean; updatedAt: string }
const now = () => new Date().toISOString();
const uid = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
const countWords = (value: string) => value.trim() ? value.trim().split(/\s+/).length : 0;
const fmt = (value: string) => new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(value));
const hash = (value: string) => { let n = 2166136261; for (let i = 0; i < value.length; i += 1) n = Math.imul(n ^ value.charCodeAt(i), 16777619); return Math.abs(n).toString(16).padStart(8, "0"); };
const clean = (md: string) => md.replace(/^#{1,6}\s+/gm, "").replace(/[*_`>]/g, "");
const escapeHtml = (v: string) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const toHtml = (md: string) => md.split("\n").map((line) => line.startsWith("# ") ? `<h1>${escapeHtml(line.slice(2))}</h1>` : line.startsWith("## ") ? `<h2>${escapeHtml(line.slice(3))}</h2>` : line.trim() ? `<p>${escapeHtml(line)}</p>` : "").join("\n");
const initial: State = {
id: "project_attention_commons", title: "The Attention Commons", subtitle: "An essay on reclaiming shared focus", phase: "ideation", privacy: "Local", cloudApproved: false, challenge: "Balanced", spokenReplies: false,
turns: [
{ id: "turn_1", speaker: "user", text: "I keep returning to the idea that attention is treated as a private resource, even though its erosion changes public life.", createdAt: "2026-07-16T17:42:00.000Z" },
{ id: "turn_2", speaker: "assistant", text: "That tension feels central: we experience distraction individually, but the consequences accumulate socially. Where do you see that shared cost most clearly?", createdAt: "2026-07-16T17:43:00.000Z" },
{ id: "turn_3", speaker: "user", text: "In classrooms, meetings, and even public debate. We have fewer places where sustained attention is expected and protected.", createdAt: "2026-07-16T17:44:00.000Z" },
],
ideas: [
{ id: "idea_1", title: "Attention as civic infrastructure", summary: "Treat sustained attention like a shared condition that institutions can protect or degrade.", detail: "Move beyond individual self-control and ask what healthy attention-supporting institutions look like.", status: "accepted", sourceTurnIds: ["turn_1", "turn_3"], parentIdeaIds: [], tags: ["thesis", "public life"], pinned: true, selected: true, group: "Core argument", createdBy: "mixed" },
{ id: "idea_2", title: "The vanishing expectation", summary: "Public spaces increasingly assume interruption instead of sustained presence.", detail: "Contrast spaces across expectations rather than generations.", status: "suggested", sourceTurnIds: ["turn_3"], parentIdeaIds: [], tags: ["observation"], pinned: false, selected: false, createdBy: "assistant" },
{ id: "idea_3", title: "Beyond digital minimalism", summary: "Personal habits matter, but they cannot repair systems designed around extraction.", detail: "A counterpoint to purely individual advice about focus.", status: "suggested", sourceTurnIds: ["turn_1"], parentIdeaIds: [], tags: ["counterpoint"], pinned: false, selected: false, createdBy: "assistant" },
],
manuscript: "# The Attention Commons\n\nWe have learned to talk about attention as if it were a private possession. We protect it with timers, muted notifications, and carefully chosen routines. Yet the places where attention matters most—classrooms, meetings, courts, and public debate—are shared. When sustained focus becomes rare, the loss does not remain private.\n\n## A public condition\n\nAttention is not simply something an individual brings into a room. It is also something the room can make possible. Norms, incentives, architecture, and technology all determine whether concentration is supported or quietly auctioned away.\n",
generation: { id: "", state: "idle", text: "", prompt: "", relation: "synthesize" },
events: [
{ id: "event_1", type: "PROJECT_CREATED", actor: "user", at: "2026-07-16T17:42:00.000Z", summary: "Created The Attention Commons", previousHash: null, hash: "1c4b790d" },
{ id: "event_2", type: "IDEA_ACCEPTED", actor: "user", at: "2026-07-16T17:45:00.000Z", summary: "Accepted “Attention as civic infrastructure”", previousHash: "1c4b790d", hash: "783fa0b2" },
],
checkpoints: [{ id: "version_1", name: "Opening premise", at: "2026-07-16T17:50:00.000Z", manuscript: "# The Attention Commons\n\nWe have learned to talk about attention as if it were a private possession.", words: 14 }],
provider: { kind: "ollama", name: "Ollama", endpoint: "http://127.0.0.1:11434", model: "llama3.2", mode: "local", connected: false },
styleTraits: ["Clear, reflective sentences", "Concrete institutional examples", "Measured, invitational argument"], disallowedHabits: ["Overstated certainty", "Generic scene-setting"], finalized: false, updatedAt: "2026-07-16T17:42:00.000Z",
};
async function invokeNative<T>(command: string, args: Record<string, unknown> = {}): Promise<T | null> { const host = window as unknown as { __TAURI_INTERNALS__?: { invoke: (name: string, payload: Record<string, unknown>) => Promise<T> } }; return host.__TAURI_INTERNALS__ ? host.__TAURI_INTERNALS__.invoke(command, args) : null; }
function download(name: string, contents: string, type = "text/plain") { const href = URL.createObjectURL(new Blob([contents], { type })); const a = document.createElement("a"); a.href = href; a.download = name; a.click(); URL.revokeObjectURL(href); }
function Mark({ children, tone = "neutral" }: { children: React.ReactNode; tone?: string }) { return <span className={`mark mark-${tone}`}>{children}</span>; }
function Empty({ eyebrow, title, body }: { eyebrow: string; title: string; body: string }) { return <div className="empty"><span className="eyebrow">{eyebrow}</span><h2>{title}</h2><p>{body}</p></div>; }
interface StructuredEditorHandle { focus: () => void; insert: (markdown: string, replace: boolean) => string; }
const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onChange: (value: string) => void; onCommit: () => void }>(function StructuredEditor({ value, onChange, onCommit }, ref) {
const instance = useEditor({
extensions: [StarterKit, Markdown],
content: value,
contentType: "markdown",
immediatelyRender: false,
editorProps: { attributes: { class: "tiptap-document", "aria-label": "Structured manuscript editor" } },
onUpdate: ({ editor }) => onChange(editor.getMarkdown()),
onBlur: onCommit,
});
useEffect(() => { if (instance && instance.getMarkdown() !== value) instance.commands.setContent(value, { contentType: "markdown", emitUpdate: false }); }, [instance, value]);
useImperativeHandle(ref, () => ({
focus: () => { instance?.commands.focus(); },
insert: (markdown, replace) => { if (!instance) return value; if (replace) instance.chain().focus().deleteSelection().insertContent(markdown, { contentType: "markdown" }).run(); else instance.chain().focus().insertContent(markdown, { contentType: "markdown" }).run(); return instance.getMarkdown(); },
}), [instance, value]);
if (!instance) return <div className="manuscript-editor editor-loading">Preparing structured editor</div>;
return <div className="manuscript-editor structured-editor"><div className="format-toolbar" aria-label="Text formatting"><button type="button" onClick={() => instance.chain().focus().toggleBold().run()} aria-pressed={instance.isActive("bold")}><strong>B</strong></button><button type="button" onClick={() => instance.chain().focus().toggleItalic().run()} aria-pressed={instance.isActive("italic")}><em>I</em></button><button type="button" onClick={() => instance.chain().focus().toggleHeading({ level: 2 }).run()} aria-pressed={instance.isActive("heading", { level: 2 })}>H2</button><button type="button" onClick={() => instance.chain().focus().toggleBulletList().run()} aria-pressed={instance.isActive("bulletList")}>List</button><button type="button" onClick={() => instance.chain().focus().undo().run()} disabled={!instance.can().undo()}>Undo</button><button type="button" onClick={() => instance.chain().focus().redo().run()} disabled={!instance.can().redo()}>Redo</button></div><EditorContent editor={instance} /></div>;
});
export default function Thinkloom() {
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch {} setHydrated(true); }); }, []);
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []);
const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); };
useEffect(() => { const shortcut = (e: KeyboardEvent) => { if (!e.altKey) return; if (e.key === "1") document.getElementById("ideas-panel")?.focus(); if (e.key === "2") editor.current?.focus(); if (e.key === "3") document.getElementById("assistant-panel")?.focus(); }; window.addEventListener("keydown", shortcut); return () => window.removeEventListener("keydown", shortcut); }, []);
const visibleIdeas = useMemo(() => project.ideas.filter((idea) => showRejected || !["rejected", "archived"].includes(idea.status)), [project.ideas, showRejected]);
const draftingIdeas = project.ideas.filter((idea) => idea.status === "accepted"); const selectedIdeas = draftingIdeas.filter((idea) => idea.selected);
const sendMessage = async () => {
const text = message.trim(); if (!text || busy) return; setMessage(""); setBusy(true);
const userTurn: Turn = { id: uid("turn"), speaker: "user", text, createdAt: now() };
mutate("USER_TEXT_TURN_CREATED", "Saved your conversation turn", (current) => ({ ...current, turns: [...current.turns, userTurn] }));
await new Promise((resolve) => setTimeout(resolve, 400));
const focus = text.replace(/[.!?].*$/, "").slice(0, 100);
const reply = project.challenge === "Gentle" ? `There is something worth staying with in “${focus}.” What personal moment makes that feel true?` : project.challenge === "Rigorous" ? `If a skeptical reader challenged “${focus},” what evidence or distinction would make it hold?` : `${focus}” suggests a useful tension. What changes when you see it as a shared condition?`;
const assistantTurn: Turn = { id: uid("turn"), speaker: "assistant", text: reply, createdAt: now() };
const suggestion: Idea = { id: uid("idea"), title: focus.length > 46 ? `${focus.slice(0, 43)}` : focus, summary: text, detail: "Explore how this supports or complicates the central argument.", status: "suggested", sourceTurnIds: [userTurn.id], parentIdeaIds: [], tags: ["from conversation"], pinned: false, selected: false, createdBy: "mixed" };
mutate("ASSISTANT_RESPONSE_GENERATED", "A response and suggested idea are ready", (current) => ({ ...current, turns: [...current.turns, assistantTurn], ideas: [...current.ideas, suggestion] }), "assistant");
if (project.spokenReplies && "speechSynthesis" in window) speechSynthesis.speak(new SpeechSynthesisUtterance(reply)); setBusy(false); requestAnimationFrame(() => conversationEnd.current?.scrollIntoView({ behavior: "smooth" }));
};
const saveLastTurn = () => { const turn = [...project.turns].reverse().find((item) => item.speaker === "user"); if (!turn) return; const idea: Idea = { id: uid("idea"), title: turn.text.slice(0, 58), summary: turn.text, detail: "Saved directly from conversation.", status: "accepted", sourceTurnIds: [turn.id], parentIdeaIds: [], tags: ["saved directly"], pinned: false, selected: true, createdBy: "user" }; mutate("IDEA_ACCEPTED", `Saved “${idea.title}” as an idea`, (current) => ({ ...current, ideas: [...current.ideas, idea] })); };
const setIdeaStatus = (id: string, status: IdeaStatus) => { const idea = project.ideas.find((item) => item.id === id); if (!idea) return; mutate(`IDEA_${status.toUpperCase()}`, `${status === "accepted" ? "Accepted" : status === "rejected" ? "Rejected" : "Archived"}${idea.title}`, (current) => ({ ...current, ideas: current.ideas.map((item) => item.id === id ? { ...item, status, selected: status === "accepted" } : item) })); };
const updateIdea = (id: string, patch: Partial<Idea>, summary = "Updated idea") => mutate("IDEA_EDITED", summary, (current) => ({ ...current, ideas: current.ideas.map((idea) => idea.id === id ? { ...idea, ...patch } : idea) }));
const createVariant = (source: Idea) => { const variant: Idea = { ...source, id: uid("idea"), title: `${source.title} — another angle`, detail: `A deliberate counter-reading of: ${source.detail}`, parentIdeaIds: [source.id], status: "suggested", selected: false, pinned: false, createdBy: "mixed" }; mutate("IDEA_VARIANT_CREATED", `Created a variant of “${source.title}`, (current) => ({ ...current, ideas: [...current.ideas, variant] })); };
const mergeSelected = () => { if (selectedIdeas.length < 2) { setNotice("Select at least two ideas to merge."); return; } const merged: Idea = { id: uid("idea"), title: selectedIdeas.map((idea) => idea.title).join(" + ").slice(0, 70), summary: selectedIdeas.map((idea) => idea.summary).join(" "), detail: "A synthesis retaining both parent ideas.", status: "accepted", sourceTurnIds: [...new Set(selectedIdeas.flatMap((idea) => idea.sourceTurnIds))], parentIdeaIds: selectedIdeas.map((idea) => idea.id), tags: [...new Set(selectedIdeas.flatMap((idea) => idea.tags))], pinned: true, selected: true, createdBy: "mixed" }; mutate("IDEAS_MERGED", `Merged ${selectedIdeas.length} ideas`, (current) => ({ ...current, ideas: [...current.ideas.map((idea) => selectedIdeas.some((selected) => selected.id === idea.id) ? { ...idea, selected: false } : idea), merged] })); };
const startGeneration = async (action = "draft") => {
if (busy) return; if (action === "draft" && !selectedIdeas.length) { setNotice("Select at least one accepted idea first."); return; } setBusy(true); const id = uid("generation"); const source = selectedIdeas.map((idea) => idea.summary).join(" ") || "the current passage"; const prompt = action === "draft" ? `Draft a passage that ${project.generation.relation}s the selected ideas.` : `${action} the selected passage.`;
mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } })); await new Promise((resolve) => setTimeout(resolve, 500));
let nativeText: string | null = null;
try { nativeText = await invokeNative<string>("generate_text", { profile: project.provider, prompt, context: source, cloudApproved: project.cloudApproved }); }
catch (error) { mutate("GENERATION_FAILED", "Provider request failed; your request is preserved for retry", (current) => ({ ...current, generation: { ...current.generation, id, state: "failed", text: "", prompt } }), "system"); setNotice(`Provider request failed: ${String(error)}`); setBusy(false); return; }
const variants: Record<string, string> = { draft: `The language of personal discipline obscures a public design problem. ${source} A communitys capacity for sustained thought depends on the expectations it builds into ordinary places: whether a classroom protects silence long enough for uncertainty, whether a meeting rewards listening before reaction, and whether public argument leaves room for ideas that cannot arrive as slogans.`, Clarity: "Attention is personal in experience but public in consequence. Institutions shape whether people can sustain it together.", Rewrite: "We guard attention as private property, yet its most important work happens between us—in classrooms, meetings, and the slow exchange of public argument.", Shorten: "Attention feels private, but its loss reshapes public life.", Expand: "Attention feels private, but its loss reshapes public life. The change appears first as friction: a classroom that cannot settle, a meeting that repeats itself, a debate that rewards instant response. Over time, those moments become an institutional condition.", Transition: "That is why the question must move beyond individual habit and toward the environments we share.", Repetition: "Consider trimming repeated uses of “private,” “shared,” and “attention” in the opening two paragraphs.", Consistency: "The draft consistently frames attention as infrastructure; keep the institutional examples parallel to maintain that logic.", Tone: "A measured version can make the claim firmly without blaming readers for systems they did not design.", "User voice": "In your reflective voice: the room matters because attention is never brought into it alone; it is invited, protected, or spent there.", Proofread: "The selected passage is mechanically clean. Consider an em dash instead of the current parenthetical pause." };
mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText ?? variants[action] ?? variants.draft, prompt } }), "assistant"); setBusy(false);
};
const acceptGeneration = (mode: "cursor" | "replace" | "append" | "section", partial = false) => { const full = project.generation.text; if (!full) return; const accepted = partial ? full.split(/(?<=[.!?])\s+/).slice(0, 2).join(" ") : full; let manuscript = project.manuscript; if ((mode === "replace" || mode === "cursor") && view === "drafting") { manuscript = editor.current?.insert(accepted, mode === "replace") ?? manuscript; } else if (mode === "replace") { const start = finalEditor.current?.selectionStart ?? manuscript.length; const end = finalEditor.current?.selectionEnd ?? start; manuscript = `${manuscript.slice(0, start)}${accepted}${manuscript.slice(end)}`; } else if (mode === "section") manuscript = `${manuscript.trimEnd()}\n\n## New section\n\n${accepted}\n`; else manuscript = `${manuscript.trimEnd()}\n\n${accepted}\n`; mutate(partial ? "GENERATION_PARTIALLY_ACCEPTED" : "GENERATION_ACCEPTED", `${partial ? "Partially accepted" : "Accepted"} generated text`, (current) => ({ ...current, manuscript, generation: { ...current.generation, state: "accepted" } })); };
const discardGeneration = () => mutate("GENERATION_REJECTED", "Discarded generated preview", (current) => ({ ...current, generation: { ...current.generation, state: "rejected", text: "" } }));
const saveCheckpoint = (name = `Version ${project.checkpoints.length + 1}`) => { const point: Checkpoint = { id: uid("version"), name, at: now(), manuscript: project.manuscript, words: countWords(project.manuscript) }; mutate("CHECKPOINT_CREATED", `Saved version “${name}`, (current) => ({ ...current, checkpoints: [...current.checkpoints, point] })); void invokeNative("create_checkpoint", { name }); };
const restoreCheckpoint = (point: Checkpoint) => mutate("VERSION_RESTORED", `Restored “${point.name}`, (current) => ({ ...current, manuscript: point.manuscript }));
const finalize = () => { const point: Checkpoint = { id: uid("release"), name: `Release ${project.checkpoints.filter((item) => item.name.startsWith("Release")).length + 1}`, at: now(), manuscript: project.manuscript, words: countWords(project.manuscript) }; mutate("RELEASE_FINALIZED", `Finalized ${point.name}`, (current) => ({ ...current, finalized: true, checkpoints: [...current.checkpoints, point] })); void invokeNative("finalize_release"); };
const exportFile = async (format: "markdown" | "html" | "text" | "evidence") => { const native = await invokeNative<string>("export_project", { format, sanitized }); if (native) { setNotice(`Exported to ${native}`); return; } if (format === "markdown") download(`${project.title}.md`, project.manuscript, "text/markdown"); if (format === "text") download(`${project.title}.txt`, clean(project.manuscript)); if (format === "html") download(`${project.title}.html`, `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(project.title)}</title><style>body{max-width:720px;margin:64px auto;font:18px/1.7 Georgia,serif;padding:24px}</style></head><body>${toHtml(project.manuscript)}</body></html>`, "text/html"); if (format === "evidence") download(`${project.title}-creative-process.json`, JSON.stringify({ schemaVersion: "1.0", projectId: project.id, createdAt: now(), sanitized, provenanceChainHead: project.events.at(-1)?.hash, manuscriptHash: hash(project.manuscript), events: sanitized ? project.events.filter((event) => !event.provider) : project.events }, null, 2), "application/json"); mutate("EXPORT_CREATED", `Created ${format} export`, (current) => current); };
const startVoice = () => { if (listening) { setListening(false); setNotice("Voice input stopped; no audio was retained."); return; } speechSynthesis?.cancel(); const host = window as unknown as { webkitSpeechRecognition?: new () => { lang: string; interimResults: boolean; start: () => void; onresult: (e: { results: ArrayLike<{ 0: { transcript: string } }> }) => void; onend: () => void; onerror: () => void } }; if (!host.webkitSpeechRecognition) { setNotice("Voice transcription is unavailable here. Typed input is ready."); return; } const recognition = new host.webkitSpeechRecognition(); recognition.lang = "en-US"; recognition.interimResults = true; recognition.onresult = (e) => setMessage(Array.from(e.results).map((result) => result[0].transcript).join("")); recognition.onend = () => { setListening(false); setNotice("Transcript ready to review. Audio was discarded."); }; recognition.onerror = () => { setListening(false); setNotice("Transcription failed. Audio was discarded; retry or type instead."); }; recognition.start(); setListening(true); setNotice("Listening… provisional transcript will appear below."); };
const verifyChain = () => { const valid = project.events.every((event, index) => event.previousHash === (index ? project.events[index - 1].hash : null)); setNotice(valid ? `History verified: ${project.events.length} linked events.` : "History needs repair. Your writing remains safely autosaved."); };
const createNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) { setNotice("Choose a folder when you are ready to create the desktop project."); return; } const result = await invokeNative<{ path: string }>("create_project", { path: folder, title: project.title }); if (!result) { setNotice("Project folders are available in the installed desktop app."); return; } setProjectPath(result.path); await invokeNative("persist_state", { appState: project }); setNotice("Self-contained project created and ready."); } catch (error) { setNotice(`Project could not be created: ${String(error)}`); } };
const openNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) return; const result = await invokeNative<{ path: string }>("open_project", { path: folder }); if (!result) { setNotice("Opening project folders is available in the installed desktop app."); return; } setProjectPath(result.path); const restored = await invokeNative<State>("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads</p></main>;
return <main className="app-shell">
<a className="skip-link" href="#workspace">Skip to workspace</a>
<header className="project-bar"><button className="brand" onClick={() => navigate("project")} aria-label="Open project overview"><span className="brand-mark">T</span><span>Thinkloom</span></button><div className="project-identity"><strong>{project.title}</strong><span>{project.subtitle}</span></div><div className="project-status"><Mark tone={project.privacy === "Local" ? "good" : project.privacy === "Cloud" ? "cloud" : "warm"}><span className="status-dot" />{project.privacy}</Mark><button className="history-state" onClick={() => navigate("provenance")}> History recorded</button><span className="save-state">Saved</span></div></header>
<nav className="main-nav" aria-label="Main navigation"><div className="phase-nav">{(["ideation", "drafting", "finalization"] as Phase[]).map((phase, index) => <button key={phase} className={view === phase ? "active" : ""} onClick={() => navigate(phase)}><span>{index + 1}</span>{phase[0].toUpperCase() + phase.slice(1)}</button>)}</div><div className="utility-nav">{(["project", "history", "provenance", "export", "settings"] as View[]).map((item) => <button key={item} className={view === item ? "active" : ""} onClick={() => navigate(item)}>{item[0].toUpperCase() + item.slice(1)}</button>)}</div></nav>
<div className="notice" role="status" aria-live="polite"><span></span>{notice}</div>
<section id="workspace" className="workspace" tabIndex={-1}>
{view === "ideation" && <section className="ideation-layout">
<div className="conversation-panel panel" aria-label="Ideation conversation"><div className="panel-heading"><div><span className="eyebrow">Open thread</span><h1>Explore the thought</h1></div><div className="segmented" aria-label="Challenge level">{(["Gentle", "Balanced", "Rigorous"] as const).map((level) => <button key={level} className={project.challenge === level ? "active" : ""} onClick={() => setProject((current) => ({ ...current, challenge: level }))}>{level}</button>)}</div></div>
<div className="conversation-stream"><div className="date-divider"><span>Today · Session 1</span></div>{project.turns.map((turn) => <article key={turn.id} className={`turn ${turn.speaker}`}><div className="turn-author">{turn.speaker === "user" ? "You" : "Thinkloom"}<time>{fmt(turn.createdAt)}</time></div><p>{turn.text}</p>{turn.speaker === "user" && <button className="inline-action" onClick={saveLastTurn}>+ Save as idea</button>}</article>)}{busy && <article className="turn assistant thinking"><div className="turn-author">Thinkloom</div><p><span /><span /><span /></p></article>}<div ref={conversationEnd} /></div>
<div className={`composer ${listening ? "listening" : ""}`}>{listening && <div className="voice-state"><span className="pulse" />Listening audio stays in memory and will be discarded</div>}<label htmlFor="message" className="sr-only">Continue the conversation</label><textarea id="message" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }} placeholder="Follow the thread…" rows={3} /><div className="composer-actions"><div><button className={`icon-button ${listening ? "danger" : ""}`} onClick={startVoice} aria-label={listening ? "Stop voice input" : "Start push-to-talk"}>{listening ? "■" : "●"}</button><span className="privacy-note">No audio retained</span></div><button className="primary-button" onClick={() => void sendMessage()} disabled={!message.trim() || busy}>Send </button></div></div>
</div>
<aside className="ideas-panel panel" aria-label="Suggested ideas"><div className="panel-heading"><div><span className="eyebrow">Idea garden</span><h2>Threads worth keeping</h2></div><Mark>{project.ideas.filter((idea) => idea.status === "suggested").length} to review</Mark></div><div className="idea-list">{visibleIdeas.map((idea) => <article key={idea.id} className={`idea-card status-${idea.status}`}><div className="idea-meta"><span>{idea.status}</span><span>{idea.sourceTurnIds.length} source{idea.sourceTurnIds.length === 1 ? "" : "s"}</span></div>{editingIdea === idea.id ? <><input className="edit-title" value={idea.title} onChange={(event) => updateIdea(idea.id, { title: event.target.value })} /><textarea value={idea.summary} onChange={(event) => updateIdea(idea.id, { summary: event.target.value })} /><button className="text-button" onClick={() => setEditingIdea(null)}>Done editing</button></> : <><h3>{idea.title}</h3><p>{idea.summary}</p></>}<div className="tag-row">{idea.tags.map((tag) => <span key={tag}>#{tag}</span>)}</div>{idea.status === "suggested" ? <div className="card-actions"><button className="accept" onClick={() => setIdeaStatus(idea.id, "accepted")}>Accept</button><button onClick={() => setIdeaStatus(idea.id, "rejected")}>Reject</button><button onClick={() => createVariant(idea)}>Variant</button></div> : <div className="card-actions"><button onClick={() => updateIdea(idea.id, { selected: !idea.selected }, `${idea.selected ? "Removed" : "Added"}${idea.title}${idea.selected ? "from" : "to"} drafting set`)}>{idea.selected ? "In drafting set" : "Add to draft"}</button><button onClick={() => setEditingIdea(idea.id)}>Edit</button><button onClick={() => setIdeaStatus(idea.id, "archived")}>Archive</button></div>}</article>)}</div><button className="show-history" onClick={() => setShowRejected((value) => !value)}>{showRejected ? "Hide" : "Show"} rejected and archived ideas</button></aside>
</section>}
{view === "drafting" && <section className="drafting-layout">
<aside id="ideas-panel" className="draft-ideas panel" tabIndex={-1} aria-label="Selected ideas"><div className="panel-heading"><div><span className="eyebrow">Drafting set</span><h2>Selected threads</h2></div></div><div className="relation"><label htmlFor="relation">Relation</label><select id="relation" value={project.generation.relation} onChange={(event) => setProject((current) => ({ ...current, generation: { ...current.generation, relation: event.target.value } }))}><option>synthesize</option><option>compare</option><option>contrast</option><option>sequence</option><option>support with evidence</option><option>separate into sections</option></select></div>{draftingIdeas.map((idea) => <label className={`draft-idea ${idea.selected ? "selected" : ""}`} key={idea.id}><input type="checkbox" checked={idea.selected} onChange={() => updateIdea(idea.id, { selected: !idea.selected }, `${idea.selected ? "Deselected" : "Selected"}${idea.title}`)} /><span><strong>{idea.title}</strong><small>{idea.summary}</small></span></label>)}<button className="secondary-button full" onClick={mergeSelected}>Merge selected</button><p className="shortcut">Focus: Alt+1</p></aside>
<section className="manuscript-panel panel" aria-label="Manuscript editor"><div className="editor-toolbar"><div><span className="eyebrow">Manuscript</span><h1>{project.title}</h1></div><div><button onClick={() => saveCheckpoint()}>Save version</button><span>{countWords(project.manuscript).toLocaleString()} words</span></div></div><StructuredEditor ref={editor} value={project.manuscript} onChange={(manuscript) => setProject((current) => ({ ...current, manuscript, updatedAt: now() }))} onCommit={() => mutate("MANUSCRIPT_TEXT_EDITED", "Autosaved manuscript edits", (current) => current)} /><div className="editor-footer"><span>Markdown structure · Autosaved locally</span><span>Focus: Alt+2</span></div></section>
<aside id="assistant-panel" className="draft-assistant panel" tabIndex={-1} aria-label="Generation assistant"><div className="panel-heading"><div><span className="eyebrow">Writing room</span><h2>Shape a passage</h2></div></div><p className="assistant-context">Using {selectedIdeas.length} selected idea{selectedIdeas.length === 1 ? "" : "s"} and your project voice. Your manuscript will not change until you choose.</p><button className="primary-button full" onClick={() => void startGeneration()} disabled={busy || !selectedIdeas.length}>{busy ? "Gathering threads…" : "Draft a passage"}</button>{project.generation.state === "staged" ? <div className="generation-preview"><div className="preview-label"><span>Preview</span><Mark tone="warm">AI draft · not inserted</Mark></div><p>{project.generation.text}</p><div className="preview-actions"><button className="primary-button" onClick={() => acceptGeneration("cursor")}>Insert at cursor</button><button onClick={() => acceptGeneration("replace")}>Replace selection</button><button onClick={() => acceptGeneration("append")}>Append</button><button onClick={() => acceptGeneration("section")}>New section</button><button onClick={() => acceptGeneration("cursor", true)}>Insert first 2 sentences</button><button onClick={() => void startGeneration()}>Regenerate</button><button className="danger-text" onClick={discardGeneration}>Discard</button></div></div> : <Empty eyebrow="Preview first" title="Nothing enters unseen" body="Choose your ideas, set their relationship, and generate a passage to review here." />}<p className="shortcut">Focus: Alt+3</p></aside>
</section>}
{view === "finalization" && <section className="finalization-layout">
<aside className="editorial-rail panel" aria-label="Editorial actions"><span className="eyebrow">Editorial pass</span><h2>Refine with intent</h2><p>Select text in the manuscript, then preview an action.</p><div className="action-stack">{["Clarity", "Rewrite", "Shorten", "Expand", "Transition", "Repetition", "Consistency", "Tone", "User voice", "Proofread"].map((action) => <button key={action} onClick={() => void startGeneration(action)}>{action}<span></span></button>)}</div></aside>
<section className="manuscript-panel panel final-manuscript"><div className="editor-toolbar"><div><span className="eyebrow">Release manuscript</span><h1>{project.title}</h1></div><Mark tone={project.finalized ? "good" : "warm"}>{project.finalized ? "Release ready" : "Working version"}</Mark></div><textarea ref={finalEditor} className="manuscript-editor" value={project.manuscript} onChange={(event) => setProject((current) => ({ ...current, manuscript: event.target.value }))} aria-label="Final manuscript" /><div className="editor-footer"><span>{countWords(project.manuscript)} words</span><span>{project.checkpoints.length} saved versions</span></div></section>
<aside className="release-panel panel"><span className="eyebrow">Release desk</span><h2>Prepare to publish</h2><div className="release-check"><span></span><div><strong>History chain intact</strong><small>{project.events.length} events linked</small></div></div><div className="release-check"><span></span><div><strong>Manuscript has a title</strong><small>{countWords(project.manuscript)} words ready</small></div></div><div className="release-check"><span>{project.checkpoints.length ? "✓" : "·"}</span><div><strong>Version saved</strong><small>{project.checkpoints.at(-1)?.name ?? "Save a version first"}</small></div></div>{project.generation.state === "staged" && <div className="generation-preview compact"><div className="preview-label"><span>Editorial preview</span><Mark tone="warm">Not applied</Mark></div><p>{project.generation.text}</p><div className="preview-actions"><button className="primary-button" onClick={() => acceptGeneration("replace")}>Apply to selection</button><button onClick={discardGeneration}>Discard</button></div></div>}<button className="secondary-button full" onClick={() => saveCheckpoint("Pre-release review")}>Save review version</button><button className="release-button" onClick={finalize}>Finalize release </button><p className="fine-print">Finalizing creates a named, restorable release. You can continue writing afterward.</p></aside>
</section>}
{view === "project" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">One publication, one place</span><h1>Project overview</h1><p>Everything needed to restore this publication travels with it.</p></div><div className="overview-grid"><article className="feature-card wide"><span className="card-index">01</span><h2>{project.title}</h2><input aria-label="Project title" value={project.title} onChange={(event) => setProject((current) => ({ ...current, title: event.target.value }))} /><textarea aria-label="Project description" value={project.subtitle} onChange={(event) => setProject((current) => ({ ...current, subtitle: event.target.value }))} /><div className="stat-row"><div><strong>{countWords(project.manuscript)}</strong><span>words</span></div><div><strong>{project.ideas.filter((idea) => idea.status === "accepted").length}</strong><span>accepted ideas</span></div><div><strong>{project.events.length}</strong><span>history events</span></div></div></article><article className="feature-card"><span className="card-index">02</span><h2>Project health</h2><p>Writing, ideas, and creative history are autosaved. Canonical files are refreshed by the desktop app.</p><div className="project-actions"><button className="primary-button" onClick={() => void createNativeProject()}>Create project folder</button><button className="secondary-button" onClick={() => void openNativeProject()}>Open existing</button><button className="secondary-button" onClick={verifyChain}>Verify history</button></div>{projectPath && <small className="project-path">{projectPath}</small>}</article><article className="feature-card"><span className="card-index">03</span><h2>Recovery</h2><p>Rotating snapshots keep the last seven valid project states outside the active folder.</p><button className="secondary-button" onClick={() => setNotice("Latest recovery point verified.")}>Check recovery point</button></article></div></section>}
{view === "history" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Restorable moments</span><h1>Version history</h1><p>Compare or return to meaningful stages without technical terminology.</p></div><div className="history-layout"><div className="version-list panel">{[...project.checkpoints].reverse().map((point, index) => <article className="version-row" key={point.id}><div className="version-symbol">{index === 0 ? "◆" : "◇"}</div><div><strong>{point.name}</strong><span>{fmt(point.at)} · {point.words} words</span></div><div><button onClick={() => setNotice(`Compared with “${point.name}”: ${countWords(project.manuscript) - point.words >= 0 ? "+" : ""}${countWords(project.manuscript) - point.words} words.`)}>Compare</button><button onClick={() => restoreCheckpoint(point)}>Restore</button></div></article>)}</div><aside className="panel history-aside"><span className="eyebrow">Current work</span><h2>{countWords(project.manuscript)} words</h2><p>{project.checkpoints.length ? `${countWords(project.manuscript) - project.checkpoints.at(-1)!.words >= 0 ? "+" : ""}${countWords(project.manuscript) - project.checkpoints.at(-1)!.words} words since ${project.checkpoints.at(-1)!.name}.` : "Save your first version when the draft reaches a meaningful moment."}</p><button className="primary-button full" onClick={() => saveCheckpoint()}>Save current version</button></aside></div></section>}
{view === "provenance" && <section className="page-layout"><div className="page-heading provenance-heading"><div><span className="eyebrow">Creative process record</span><h1>How this work took shape</h1><p>A linked record of decisions and contributionsnot a score or legal conclusion.</p></div><button className="secondary-button" onClick={verifyChain}>Verify linked history</button></div><div className="provenance-layout"><div className="timeline panel">{[...project.events].reverse().map((event) => <article className="event-row" key={event.id}><div className={`actor-mark actor-${event.actor}`}>{event.actor === "user" ? "Y" : event.actor === "assistant" ? "A" : "S"}</div><div><div className="event-top"><strong>{event.summary}</strong><time>{fmt(event.at)}</time></div><p>{event.type.replaceAll("_", " ").toLowerCase()}{event.provider ? ` · ${event.provider}` : ""}</p><code>{event.hash}</code></div></article>)}</div><aside className="panel contribution"><span className="eyebrow">Contribution threads</span><h2>Relationships, not percentages</h2><div className="contribution-chain"><span>You explored a question</span><i></i><span>Ideas were suggested</span><i></i><span>You accepted and shaped them</span><i></i><span>Drafts were previewed</span><i></i><span>You decided what entered the work</span></div><p>Every accepted passage remains connected to source ideas and later revisions.</p></aside></div></section>}
{view === "export" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Take your work with you</span><h1>Publish, preserve, or share</h1><p>Exports are created from the current release manuscript and checked before completion.</p></div><div className="export-grid"><article className="export-card"><span className="export-type">Publication</span><h2>Reading files</h2><p>Clean files for editors, websites, and your own archive.</p><div className="export-buttons"><button onClick={() => void exportFile("markdown")}>Markdown <span>.md</span></button><button onClick={() => void exportFile("html")}>Web page <span>.html</span></button><button onClick={() => void exportFile("text")}>Plain text <span>.txt</span></button><button onClick={() => setNotice("PDF export is available in the installed desktop app.")}>Print-ready <span>.pdf</span></button></div></article><article className="export-card featured"><span className="export-type">Preservation</span><h2>Project backup</h2><p>A complete, restorable package with manuscript, ideas, history, and recovery data.</p><button className="primary-button" onClick={() => void invokeNative<string>("create_backup").then((path) => setNotice(path ? `Backup created at ${path}` : "Project Backup ZIP is available in the desktop app."))}>Create project backup</button></article><article className="export-card"><span className="export-type">Creative process</span><h2>Authorship evidence</h2><p>A human-readable and machine-verifiable record of how the publication developed.</p><label className="toggle-row"><input type="checkbox" checked={sanitized} onChange={(event) => setSanitized(event.target.checked)} /><span><strong>Share a sanitized subset</strong><small>Excludes private conversations, provider details, identifiers, and internal paths.</small></span></label><button className="secondary-button" onClick={() => void exportFile("evidence")}>Create evidence package</button></article></div></section>}
{view === "settings" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Private by design</span><h1>Settings</h1><p>Choose where thinking happens and how Thinkloom supports the work.</p></div><div className="settings-grid">
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">01</span><div><h2>Model provider</h2><p>Your active writing assistant.</p></div></div><label>Provider<select value={project.provider.kind} onChange={(event) => { const kind = event.target.value as Provider["kind"]; const profile: Provider = kind === "ollama" ? { kind, name: "Ollama", endpoint: "http://127.0.0.1:11434", model: "llama3.2", mode: "local", connected: false } : kind === "openai" ? { kind, name: "OpenAI", endpoint: "https://api.openai.com/v1", model: "gpt-4.1-mini", mode: "cloud", connected: false } : { kind, name: "Compatible endpoint", endpoint: "http://127.0.0.1:1234/v1", model: "local-model", mode: "local", connected: false }; mutate("PROVIDER_CHANGED", `Changed provider to ${profile.name}`, (current) => ({ ...current, provider: profile, privacy: profile.mode === "local" ? "Local" : "Cloud" })); }}><option value="ollama">Ollama · Local</option><option value="openai">OpenAI · Cloud</option><option value="compatible">OpenAI-compatible</option></select></label><label>Endpoint<input value={project.provider.endpoint} onChange={(event) => setProject((current) => ({ ...current, provider: { ...current.provider, endpoint: event.target.value } }))} /></label><label>Model<input value={project.provider.model} onChange={(event) => setProject((current) => ({ ...current, provider: { ...current.provider, model: event.target.value } }))} /></label>{project.provider.kind !== "ollama" && <label>Credential<input type="password" autoComplete="new-password" value={credential} onChange={(event) => setCredential(event.target.value)} placeholder="Stored only in your system vault" /><button className="secondary-button" type="button" onClick={() => void invokeNative("store_provider_secret", { profileId: project.provider.kind, secret: credential }).then(() => { setCredential(""); setNotice("Credential saved in the operating-system vault."); }).catch((error) => setNotice(`Credential could not be saved: ${String(error)}`))}>Save securely</button></label>}{project.provider.mode === "cloud" && !project.cloudApproved && <div className="cloud-warning"><strong>Cloud approval required</strong><p>Your first cloud request sends only relevant context shown in preview.</p><button onClick={() => mutate("CLOUD_PROCESSING_APPROVED", "Approved cloud processing for this project", (current) => ({ ...current, cloudApproved: true }))}>Approve for this project</button></div>}<button className="secondary-button" onClick={() => void invokeNative<{ ok: boolean; message: string }>("test_provider", { profile: project.provider }).then((result) => { setProject((current) => ({ ...current, provider: { ...current.provider, connected: Boolean(result?.ok) } })); setNotice(result?.message ?? "Provider testing is available in the desktop app."); })}>Test connection</button></section>
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">02</span><div><h2>Voice & listening</h2><p>Audio is processed in memory and never retained.</p></div></div><label className="toggle-row"><input type="checkbox" checked={project.spokenReplies} onChange={(event) => setProject((current) => ({ ...current, spokenReplies: event.target.checked }))} /><span><strong>Spoken assistant replies</strong><small>Always accompanied by visible text. Off by default.</small></span></label><button className="secondary-button" onClick={startVoice}>Test microphone</button></section>
<section className="settings-section panel"><div className="settings-heading"><span className="settings-number">03</span><div><h2>Your writing voice</h2><p>Inspectable traits guide suggestions without impersonating you.</p></div></div><div className="trait-list">{project.styleTraits.map((trait, index) => <label key={`${index}-${trait}`}><span className="confidence">Developing</span><input value={trait} onChange={(event) => setProject((current) => ({ ...current, styleTraits: current.styleTraits.map((item, itemIndex) => itemIndex === index ? event.target.value : item) }))} /></label>)}</div><label>Habit to avoid<div className="inline-input"><input value={newHabit} onChange={(event) => setNewHabit(event.target.value)} placeholder="Add a pattern to avoid" /><button onClick={() => { if (!newHabit.trim()) return; mutate("STYLE_PROFILE_UPDATED", "Updated writing voice profile", (current) => ({ ...current, disallowedHabits: [...current.disallowedHabits, newHabit.trim()] })); setNewHabit(""); }}>Add</button></div></label><div className="tag-row">{project.disallowedHabits.map((habit) => <span key={habit}>{habit}</span>)}</div></section>
</div></section>}
</section>
<footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
</main>;
}
+6 -12
View File
@@ -1,13 +1,7 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
/** Create a typed database client from the D1 binding supplied by the worker. */
export function getDb(binding: D1Database) {
return drizzle(binding, { schema });
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f1e9" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<title>Thinkloom</title>
</head>
<body><div id="root"></div><script type="module" src="/main.tsx"></script></body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import React from "react";
import { createRoot } from "react-dom/client";
import Thinkloom from "../app/thinkloom";
import "../app/globals.css";
createRoot(document.getElementById("root")!).render(<React.StrictMode><Thinkloom /></React.StrictMode>);
+921 -5
View File
File diff suppressed because it is too large Load Diff
+17 -7
View File
@@ -1,19 +1,27 @@
{
"name": "site-creator-vinext-starter",
"name": "thinkloom",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
"dev": "vinext dev",
"build": "vinext build",
"start": "vinext start",
"test": "npm run typecheck && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern desktop-dist --ignore-pattern src-tauri/target",
"db:generate": "drizzle-kit generate",
"typecheck": "tsc --noEmit",
"desktop:dev": "vite --config vite.desktop.config.ts",
"desktop:build": "vite build --config vite.desktop.config.ts",
"tauri": "tauri"
},
"dependencies": {
"@tiptap/markdown": "^3.28.0",
"@tiptap/pm": "^3.28.0",
"@tiptap/react": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"drizzle-orm": "0.45.2",
"next": "16.2.6",
"react": "19.2.6",
@@ -21,7 +29,9 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@cloudflare/workers-types": "^4.20260702.1",
"@tailwindcss/postcss": "4.2.1",
"@tauri-apps/cli": "^2.11.4",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+5526
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "thinkloom"
version = "0.1.0"
description = "Local-first writing studio with creative provenance"
authors = ["Thinkloom"]
edition = "2021"
rust-version = "1.77.2"
[lib]
name = "thinkloom_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
sha2 = "0.10"
hex = "0.4"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
keyring = "3"
rfd = "0.15"
walkdir = "2"
tempfile = "3"
[dev-dependencies]
pretty_assertions = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Core Thinkloom desktop capability",
"windows": ["main"],
"permissions": ["core:default"]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"main-capability":{"identifier":"main-capability","description":"Core Thinkloom desktop capability","local":true,"windows":["main"],"permissions":["core:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+1301
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
fn main() {
thinkloom_lib::run();
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Thinkloom",
"version": "0.1.0",
"identifier": "com.thinkloom.desktop",
"build": {
"beforeDevCommand": "npm run desktop:dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run desktop:build",
"frontendDist": "../desktop-dist"
},
"app": {
"windows": [{
"title": "Thinkloom",
"width": 1440,
"height": 920,
"minWidth": 960,
"minHeight": 680,
"resizable": true,
"fullscreen": false,
"center": true
}],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'"
}
},
"bundle": {
"active": true,
"targets": ["msi", "nsis"],
"shortDescription": "Ideas into writing, without losing the thread.",
"longDescription": "A local-first writing studio for exploring ideas, shaping drafts, and preserving creative process history.",
"category": "Productivity",
"copyright": "Copyright © 2026 Thinkloom"
}
}
+42 -86
View File
@@ -1,87 +1,43 @@
import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises";
import test from "node:test";
const developmentPreviewMeta =
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
const templateRoot = new URL("../", import.meta.url);
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(
new Request("http://localhost/", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async () => new Response("Not found", { status: 404 }),
},
},
{
waitUntil() {},
passThroughOnException() {},
},
);
}
test("server-renders the starter loading skeleton", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, developmentPreviewMeta);
assert.match(html, /<title>Your site is taking shape<\/title>/i);
assert.match(html, /Codex is working/);
assert.match(html, /Your site is taking shape/);
assert.match(html, /Codex is building the first version/);
assert.match(html, /react-loading-skeleton/);
assert.match(html, /role="status"/);
});
test("keeps the loading skeleton scoped and disposable", async () => {
const [preview, css, page, layout, packageJson, files] = await Promise.all([
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
readFile(new URL("preview.css", previewRoot), "utf8"),
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readdir(previewRoot),
]);
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
assert.match(preview, /from "react-loading-skeleton"/);
assert.match(preview, /baseColor="#eceae7"/);
assert.match(preview, /highlightColor="#f9f8f6"/);
assert.match(preview, /duration=\{2\.8\}/);
assert.match(preview, /sites-skeleton-search-placeholder/);
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
assert.match(css, /position:\s*fixed/);
assert.match(css, /inset:\s*0/);
assert.match(css, /opacity:\s*0\.52/);
assert.match(css, /prefers-reduced-motion:\s*reduce/);
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
assert.doesNotMatch(
preview,
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
);
assert.match(page, /export const metadata:\s*Metadata/);
assert.match(page, /"codex-preview": "development"/);
assert.match(page, /<SkeletonPreview \/>/);
assert.match(layout, /title:\s*"Starter Project"/);
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
await assert.rejects(
access(new URL("public/_sites-preview", templateRoot)),
);
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const root = new URL("../", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(new Request("http://localhost/", { headers: { accept: "text/html" } }), { ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } }, { waitUntil() {}, passThroughOnException() {} });
}
test("serves the Thinkloom product shell", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /Thinkloom — ideas into writing/i);
assert.match(html, /Gathering your threads/i);
assert.doesNotMatch(html, /codex-preview|starter project|react-loading-skeleton/i);
});
test("implements the control and privacy contracts", async () => {
const [source, css, layout] = await Promise.all([
readFile(new URL("../app/thinkloom.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]);
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) assert.match(source, new RegExp(phrase, "i"));
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
assert.match(source, /store_provider_secret/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /:focus-visible/);
assert.match(layout, /Thinkloom — ideas into writing/);
});
test("never ships retained audio assets", async () => {
async function walk(url) { const entries = await readdir(url, { withFileTypes: true }); const files = []; for (const entry of entries) { if (["node_modules", "dist", "desktop-dist", "target", ".git"].includes(entry.name)) continue; const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url); if (entry.isDirectory()) files.push(...await walk(child)); else files.push(entry.name); } return files; }
const files = await walk(root);
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
});
+5 -1
View File
@@ -3,6 +3,7 @@
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"types": ["@cloudflare/workers-types"],
"skipLibCheck": true,
"strict": true,
"noEmit": true,
@@ -30,5 +31,8 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "examples"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
root: "desktop",
build: { outDir: "../desktop-dist", emptyOutDir: true },
server: { port: 1420, strictPort: true },
clearScreen: false,
});