"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, "&").replace(//g, ">");
const toHtml = (md: string) => md.split("\n").map((line) => line.startsWith("# ") ? `
${escapeHtml(line.slice(2))} ` : line.startsWith("## ") ? `${escapeHtml(line.slice(3))} ` : line.trim() ? `${escapeHtml(line)}
` : "").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",
};
function createEmptyProject(id = uid("project")): State {
const createdAt = now();
const title = "Untitled Project";
const summary = `Created ${title}`;
return {
id, title, subtitle: "", phase: "ideation", privacy: "Local", cloudApproved: false, challenge: "Balanced", spokenReplies: false,
turns: [], ideas: [], manuscript: "",
generation: { id: "", state: "idle", text: "", prompt: "", relation: "synthesize" },
events: [{ id: uid("event"), type: "PROJECT_CREATED", actor: "user", at: createdAt, summary, previousHash: null, hash: hash(`|PROJECT_CREATED|${summary}|${createdAt}`) }],
checkpoints: [],
provider: { kind: "ollama", name: "Ollama", endpoint: "http://127.0.0.1:11434", model: "llama3.2", mode: "local", connected: false },
styleTraits: [], disallowedHabits: [], finalized: false, updatedAt: createdAt,
};
}
async function invokeNative(command: string, args: Record = {}): Promise { const host = window as unknown as { __TAURI_INTERNALS__?: { invoke: (name: string, payload: Record) => Promise } }; return host.__TAURI_INTERNALS__ ? host.__TAURI_INTERNALS__.invoke(command, args) : null; }
function nativeError(error: unknown): string { if (error && typeof error === "object") { const value = error as { message?: unknown; code?: unknown }; if (typeof value.message === "string") return value.code ? `${String(value.code)}: ${value.message}` : value.message; } return String(error); }
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 {children} ; }
function Empty({ eyebrow, title, body }: { eyebrow: string; title: string; body: string }) { return ; }
interface StructuredEditorHandle { focus: () => void; insert: (markdown: string, replace: boolean) => string; }
const StructuredEditor = forwardRef 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 Preparing structured editor…
;
return instance.chain().focus().toggleBold().run()} aria-pressed={instance.isActive("bold")}>B instance.chain().focus().toggleItalic().run()} aria-pressed={instance.isActive("italic")}>I instance.chain().focus().toggleHeading({ level: 2 }).run()} aria-pressed={instance.isActive("heading", { level: 2 })}>H2 instance.chain().focus().toggleBulletList().run()} aria-pressed={instance.isActive("bulletList")}>List instance.chain().focus().undo().run()} disabled={!instance.can().undo()}>Undo instance.chain().focus().redo().run()} disabled={!instance.can().redo()}>Redo
;
});
export default function Thinkloom() {
const [project, setProject] = useState(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState("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(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const [promptPath, setPromptPath] = useState(""); const editor = useRef(null); const finalEditor = useRef(null); const conversationEnd = useRef(null);
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch { /* Ignore invalid local recovery state. */ } setHydrated(true); }); }, []);
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
useEffect(() => { void invokeNative<{ directory: string }>("ensure_prompt_files").then((info) => { if (info?.directory) setPromptPath(info.directory); }).catch((error) => setNotice(`Prompt configuration could not be prepared: ${nativeError(error)}`)); }, []);
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] }));
const conversation = [...project.turns, userTurn].slice(-10).map((turn) => `${turn.speaker === "user" ? "Writer" : "Thinkloom"}: ${turn.text}`).join("\n");
let reply: string;
try {
const generated = await invokeNative("generate_text", { profile: project.provider, promptVariables: { challenge: project.challenge, context: conversation }, cloudApproved: project.cloudApproved, purpose: "conversation" });
if (!generated?.trim()) throw new Error("The provider returned an empty response.");
reply = generated.trim();
setProject((current) => ({ ...current, provider: { ...current.provider, connected: true } }));
} catch (error) {
setProject((current) => ({ ...current, provider: { ...current.provider, connected: false } }));
setNotice(`${project.provider.name} did not reply: ${nativeError(error)} Check Settings → Model provider, then retry.`);
setBusy(false); return;
}
const focus = text.replace(/[.!?].*$/, "").slice(0, 100);
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", `Response generated by ${project.provider.name}`, (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, 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;
mutate("GENERATION_REQUESTED", `Started ${action.toLowerCase()} preview`, (current) => ({ ...current, generation: { ...current.generation, id, state: "streaming", text: "", prompt } }));
try {
const nativeText = await invokeNative("generate_text", {
profile: project.provider,
promptVariables: { action, relation: project.generation.relation, context: source },
cloudApproved: project.cloudApproved,
purpose: "drafting",
});
if (!nativeText?.trim()) throw new Error("The provider returned an empty response.");
mutate("GENERATION_COMPLETED", "Preview ready — your manuscript is unchanged", (current) => ({ ...current, generation: { ...current.generation, id, state: "staged", text: nativeText.trim(), prompt } }), "assistant");
} 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: ${nativeError(error)}`);
} finally {
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("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`, `${escapeHtml(project.title)} ${toHtml(project.manuscript)}`, "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 createEmptyNativeProject = async () => {
const hasWork = Boolean(project.turns.length || project.ideas.length || project.manuscript.trim() || project.checkpoints.length);
if (hasWork && !window.confirm("Start a new empty project? Your current project will remain in its existing folder, but unsaved in-app changes should be saved first.")) return;
try {
const folder = await invokeNative("choose_project_folder");
if (!folder) { setNotice("New project cancelled. Your current project is unchanged."); return; }
const blank = createEmptyProject();
const result = await invokeNative<{ path: string; manifest: { id: string } }>("create_project", { path: folder, title: blank.title });
if (!result) { setNotice("Creating project folders is available in the installed desktop app."); return; }
const next = { ...blank, id: result.manifest.id };
setProject(next); setProjectPath(result.path); setMessage(""); setShowRejected(false); setEditingIdea(null); setView("ideation");
localStorage.setItem("thinkloom-project-v1", JSON.stringify(next));
await invokeNative("persist_state", { appState: next });
setNotice("New empty project created. Give it a title when you are ready.");
} catch (error) { setNotice(`New project could not be created: ${String(error)}`); }
};
const createNativeProject = async () => { try { const folder = await invokeNative("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("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("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
const openPromptFolder = async () => { try { const path = await invokeNative("open_prompt_folder"); if (path) { setPromptPath(path); setNotice("Prompt configuration folder opened."); } } catch (error) { setNotice(`Prompt configuration folder could not be opened: ${nativeError(error)}`); } };
if (!hydrated) return T
Gathering your threads…
;
return
Skip to workspace
navigate("project")} aria-label="Open project overview">T Thinkloom {project.title} {project.subtitle || "New project"}
void createEmptyNativeProject()}>+ New project {project.privacy} navigate("provenance")}>◇ History recorded Saved
{(["ideation", "drafting", "finalization"] as Phase[]).map((phase, index) => navigate(phase)}>{index + 1} {phase[0].toUpperCase() + phase.slice(1)} )}
{(["project", "history", "provenance", "export", "settings"] as View[]).map((item) => navigate(item)}>{item[0].toUpperCase() + item.slice(1)} )}
✦ {notice}
{view === "ideation" &&
Open thread
Explore the thought {(["Gentle", "Balanced", "Rigorous"] as const).map((level) => setProject((current) => ({ ...current, challenge: level }))}>{level} )}
Today · Session 1
{project.turns.map((turn) =>
{turn.speaker === "user" ? "You" : "Thinkloom"}{fmt(turn.createdAt)}
{turn.text}
{turn.speaker === "user" && + Save as idea })}{busy &&
Thinkloom
}
{listening &&
Listening — audio stays in memory and will be discarded
}
Continue the conversation
}
{view === "drafting" &&
Drafting set
Selected threads Relation setProject((current) => ({ ...current, generation: { ...current.generation, relation: event.target.value } }))}>synthesize compare contrast sequence support with evidence separate into sections
{draftingIdeas.map((idea) => updateIdea(idea.id, { selected: !idea.selected }, `${idea.selected ? "Deselected" : "Selected"} “${idea.title}”`)} />{idea.title} {idea.summary} )}Merge selected Focus: Alt+1
Manuscript
{project.title} saveCheckpoint()}>Save version {countWords(project.manuscript).toLocaleString()} words
setProject((current) => ({ ...current, manuscript, updatedAt: now() }))} onCommit={() => mutate("MANUSCRIPT_TEXT_EDITED", "Autosaved manuscript edits", (current) => current)} />Markdown structure · Autosaved locally Focus: Alt+2
Writing room
Shape a passage Using {selectedIdeas.length} selected idea{selectedIdeas.length === 1 ? "" : "s"} and your project voice. Your manuscript will not change until you choose.
void startGeneration()} disabled={busy || !selectedIdeas.length}>{busy ? "Gathering threads…" : "Draft a passage"} {project.generation.state === "staged" ? Preview AI draft · not inserted
{project.generation.text}
acceptGeneration("cursor")}>Insert at cursor acceptGeneration("replace")}>Replace selection acceptGeneration("append")}>Append acceptGeneration("section")}>New section acceptGeneration("cursor", true)}>Insert first 2 sentences void startGeneration()}>Regenerate Discard
: }Focus: Alt+3
}
{view === "finalization" &&
Editorial pass Refine with intent Select text in the manuscript, then preview an action.
{["Clarity", "Rewrite", "Shorten", "Expand", "Transition", "Repetition", "Consistency", "Tone", "User voice", "Proofread"].map((action) => void startGeneration(action)}>{action}→ )}
Release manuscript
{project.title} {project.finalized ? "Release ready" : "Working version"}
Release desk Prepare to publish ✓ History chain intact {project.events.length} events linked
✓ Manuscript has a title {countWords(project.manuscript)} words ready
{project.checkpoints.length ? "✓" : "·"} Version saved {project.checkpoints.at(-1)?.name ?? "Save a version first"}
{project.generation.state === "staged" && Editorial preview Not applied
{project.generation.text}
acceptGeneration("replace")}>Apply to selection Discard
} saveCheckpoint("Pre-release review")}>Save review version Finalize release ↗ Finalizing creates a named, restorable release. You can continue writing afterward.
}
{view === "project" && One publication, one place Project overview Everything needed to restore this publication travels with it.
01 {project.title} setProject((current) => ({ ...current, title: event.target.value }))} />02 Project health Writing, ideas, and creative history are autosaved. Canonical files are refreshed by the desktop app.
void createEmptyNativeProject()}>New empty project void createNativeProject()}>Save current to folder void openNativeProject()}>Open existing Verify history
{projectPath && {projectPath} }03 Recovery Rotating snapshots keep the last seven valid project states outside the active folder.
setNotice("Latest recovery point verified.")}>Check recovery point }
{view === "history" && Restorable moments Version history Compare or return to meaningful stages without technical terminology.
{[...project.checkpoints].reverse().map((point, index) =>
{index === 0 ? "◆" : "◇"}
{point.name} {fmt(point.at)} · {point.words} words
setNotice(`Compared with “${point.name}”: ${countWords(project.manuscript) - point.words >= 0 ? "+" : ""}${countWords(project.manuscript) - point.words} words.`)}>Compare restoreCheckpoint(point)}>Restore
)}
Current work {countWords(project.manuscript)} words {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."}
saveCheckpoint()}>Save current version }
{view === "provenance" && Creative process record How this work took shape A linked record of decisions and contributions—not a score or legal conclusion.
Verify linked history {[...project.events].reverse().map((event) =>
{event.actor === "user" ? "Y" : event.actor === "assistant" ? "A" : "S"}
{event.summary} {fmt(event.at)}
{event.type.replaceAll("_", " ").toLowerCase()}{event.provider ? ` · ${event.provider}` : ""}
{event.hash})}
Contribution threads Relationships, not percentages You explored a question ↓ Ideas were suggested ↓ You accepted and shaped them ↓ Drafts were previewed ↓ You decided what entered the work
Every accepted passage remains connected to source ideas and later revisions.
}
{view === "export" && Take your work with you Publish, preserve, or share Exports are created from the current release manuscript and checked before completion.
Publication Reading files Clean files for editors, websites, and your own archive.
void exportFile("markdown")}>Markdown .md void exportFile("html")}>Web page .html void exportFile("text")}>Plain text .txt setNotice("PDF export is available in the installed desktop app.")}>Print-ready .pdf
Preservation Project backup A complete, restorable package with manuscript, ideas, history, and recovery data.
void invokeNative("create_backup").then((path) => setNotice(path ? `Backup created at ${path}` : "Project Backup ZIP is available in the desktop app."))}>Create project backup Creative process Authorship evidence A human-readable and machine-verifiable record of how the publication developed.
setSanitized(event.target.checked)} />Share a sanitized subset Excludes private conversations, provider details, identifiers, and internal paths. void exportFile("evidence")}>Create evidence package }
{view === "settings" && Private by design Settings Choose where thinking happens and how Thinkloom supports the work.
01 Model provider Your active writing assistant.
Provider { 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" })); }}>Ollama · Local OpenAI · Cloud OpenAI-compatible Endpoint setProject((current) => ({ ...current, provider: { ...current.provider, endpoint: event.target.value } }))} /> Model setProject((current) => ({ ...current, provider: { ...current.provider, model: event.target.value } }))} /> {project.provider.kind !== "ollama" && Credential setCredential(event.target.value)} placeholder="Stored only in your system vault" /> 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 }{project.provider.mode === "cloud" && !project.cloudApproved && Cloud approval required Your first cloud request sends only relevant context shown in preview.
mutate("CLOUD_PROCESSING_APPROVED", "Approved cloud processing for this project", (current) => ({ ...current, cloudApproved: true }))}>Approve for this project } 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
04 Prompt configuration Technical users can tune every instruction sent to the model.
conversation.json affects Ideation replies. drafting.json affects passage and editorial previews. Files reload before every model request, so no restart is needed.
{promptPath || "Preparing prompt files…"} void openPromptFolder()}>Open prompt folder
README.md in this folder documents every field, variable, effect, validation rule, and reset procedure.
}
Thinkloom 0.3.0 Local-first · audio retention always off {project.events.at(-1)?.hash ?? "No history"}
;
}