Files
thinkloom-openai-hackathon/app/thinkloom.tsx
T

170 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>;
}