diff --git a/src/Thinkloom.tsx b/src/Thinkloom.tsx index 34c3252..edd3977 100644 --- a/src/Thinkloom.tsx +++ b/src/Thinkloom.tsx @@ -47,6 +47,21 @@ const initial: State = { 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 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}; } @@ -116,13 +131,29 @@ export default function Thinkloom() { 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)}`); } }; if (!hydrated) return
T

Gathering your threads…

; return
Skip to workspace -
{project.title}{project.subtitle}
{project.privacy}Saved
+
{project.title}{project.subtitle || "New project"}
{project.privacy}Saved
{notice}
@@ -146,7 +177,7 @@ export default function Thinkloom() {
} - {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 }))} />