From 4b8dc903e46cd3ff7b293391e3cbb1b88955fe35 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Sun, 19 Jul 2026 07:54:12 -0700 Subject: [PATCH] feat: rebuild Phase 1 ideation and curation workflow --- PROMPTS.md | 8 +-- README.md | 7 ++- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/prompts/conversation.json | 6 ++- src-tauri/prompts/drafting.json | 3 +- src-tauri/src/lib.rs | 55 +++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- src/Thinkloom.tsx | 83 ++++++++++++++++++++--------- src/globals.css | 6 +++ tests/native-app.test.mjs | 10 +++- 13 files changed, 145 insertions(+), 45 deletions(-) diff --git a/PROMPTS.md b/PROMPTS.md index c028ebe..4ef2972 100644 --- a/PROMPTS.md +++ b/PROMPTS.md @@ -1,18 +1,18 @@ # Thinkloom prompt configuration -Thinkloom 0.4.0 exposes every instruction sent to a language model as editable JSON. The desktop app creates a prompts folder in its operating-system configuration directory and shows its exact path under Settings → Prompt configuration. Use Open prompt folder to open it. +Thinkloom 0.5.0 exposes every instruction sent to a language model as editable JSON. The desktop app creates a prompts folder in its operating-system configuration directory and shows its exact path under Settings → Prompt configuration. Use Open prompt folder to open it. -Prompt files are loaded immediately before every model request. Save a valid edit, then make the next request; no restart or rebuild is required. Thinkloom never overwrites existing user prompt files during startup or an update. +Prompt files are loaded immediately before every model request. Save a valid edit, then make the next request; no restart or rebuild is required. Thinkloom preserves existing prompt instructions. When a release introduces a required prompt field, it adds only the missing field and wraps legacy conversation instructions with the new modular context variables. ## Files and effects ### conversation.json -Affects replies in Ideation. systemPrompt defines the overall role, userPromptTemplate defines the turn task, and challengeGuidance supplies the Gentle, Balanced, or Rigorous instruction. The {{challenge_guidance}} and {{context}} placeholders are required. +Affects replies in Ideation. systemPrompt defines the overall role, userPromptTemplate defines the turn task, and challengeGuidance supplies the Gentle, Balanced, or Rigorous instruction. The {{persona_instruction}}, {{genre_instruction}}, {{lore_context}}, {{web_search_instruction}}, {{challenge_guidance}}, and {{context}} placeholders are required. Together they form the modular system instruction for each session. ### drafting.json -Affects passage previews in Drafting and editorial previews in Finalization. systemPrompt defines the overall role, draftPromptTemplate is used by Draft a passage, and editorialPromptTemplate is used by the editorial actions. Available placeholders are {{relation}}, {{action}}, and {{context}}. +Affects passage previews in Drafting and editorial previews in Finalization. systemPrompt defines the overall role, draftPromptTemplate is used by Draft a passage, editorialPromptTemplate is used by the editorial actions, and distillationPromptTemplate turns the Phase 1 drafting paper into a token-efficient handoff. Available placeholders are {{relation}}, {{action}}, and {{context}}. The description, effect, and variables objects document the configuration and are not sent to the model. diff --git a/README.md b/README.md index dd9c63a..be7ed6a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ See [CODEX_COLLABORATION_LOG.md](CODEX_COLLABORATION_LOG.md) for the complete pr ## Implemented workflow - Reversible Ideation, Drafting, and Finalization phases -- Typed conversation, challenge levels, push-to-talk transcription, and optional visible speech output +- Three-pane Phase 1 workspace for session context, live conversation, and idea distillation +- Persona, genre, lore, and provider-aware web-search guidance assembled into each conversational instruction +- Per-message Append to Ideas controls, editable drafting paper, one-off LLM distillation, and structured session export +- Typed conversation, challenge levels, push-to-talk transcription, persona-linked speech output, and no retained audio - Suggested ideas with explicit accept/reject, editing, variants, archiving, source links, drafting sets, and merges - TipTap/ProseMirror structured manuscript editor with canonical Markdown round-tripping, undo/redo, headings, lists, selection replacement, and cursor insertion - Persisted preview-first generation states with retry-safe provider errors and partial acceptance @@ -88,7 +91,7 @@ See [PROMPTS.md](PROMPTS.md) for each file's effect, variables, editing workflow The approved Thinkloom 1.0 provenance architecture is documented in the [Stage 1 normative provenance specification](docs/provenance/README.md). It defines the future single-writer subsystem, canonical records, segmented ledger, recovery protocol, retention policy, native verification, and release bindings. -Thinkloom 0.4.0 extends the formal Stage 2 package with canonical provenance assertions, point-in-time evaluations, versioned semantic registries, and deterministic invalidation vectors. The current native provenance writer must not be represented as conforming until the later implementation and fault-injection stages are complete. +Thinkloom 0.5.0 extends the formal Stage 2 package with canonical provenance assertions, point-in-time evaluations, versioned semantic registries, and deterministic invalidation vectors. The current native provenance writer must not be represented as conforming until the later implementation and fault-injection stages are complete. ## Provider setup diff --git a/package-lock.json b/package-lock.json index 9a0ac87..e857b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "thinkloom", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "thinkloom", - "version": "0.4.0", + "version": "0.5.0", "license": "AGPL-3.0-only", "dependencies": { "@tiptap/markdown": "^3.28.0", diff --git a/package.json b/package.json index 85a6b0d..943e6f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thinkloom", - "version": "0.4.0", + "version": "0.5.0", "author": "Christopher Chambers", "license": "AGPL-3.0-only", "repository": "https://github.com/Labyricorn/thinkloom-openai-hackathon.git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2529f55..44d3806 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3980,7 +3980,7 @@ dependencies = [ [[package]] name = "thinkloom" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15d6f04..0714c53 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkloom" -version = "0.4.0" +version = "0.5.0" description = "Local-first writing studio with creative provenance" authors = ["Christopher Chambers"] license = "AGPL-3.0-only" diff --git a/src-tauri/prompts/conversation.json b/src-tauri/prompts/conversation.json index e2cadcc..106f17a 100644 --- a/src-tauri/prompts/conversation.json +++ b/src-tauri/prompts/conversation.json @@ -3,7 +3,7 @@ "id": "conversation", "description": "Controls Thinkloom replies during the Ideation conversation.", "effect": "Changes how the assistant reflects on the writer's latest message and which single follow-up question it asks.", - "systemPrompt": "You are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked.", + "systemPrompt": "{{persona_instruction}}\n\n{{genre_instruction}}\n\nProject lore and context:\n{{lore_context}}\n\n{{web_search_instruction}}\n\nYou are Thinkloom, a focused writing collaborator in an ideation conversation. Respond naturally to the writer's latest message, briefly reflect what is useful, and ask exactly one focused question. Do not force a stock interpretation, fabricate details, or draft prose unless asked.", "userPromptTemplate": "Continue this ideation conversation. Respond directly and naturally to the writer's latest message, then ask exactly one focused question that helps develop their writing. {{challenge_guidance}}\n\nConversation so far:\n{{context}}", "challengeGuidance": { "Gentle": "Be warm and exploratory; help the writer locate a concrete personal example.", @@ -12,6 +12,10 @@ }, "variables": { "challenge_guidance": "Selected from challengeGuidance using the current Gentle, Balanced, or Rigorous setting.", + "persona_instruction": "Instruction assembled from the selected Supportive Coach, Critical Editor, or Creative Partner persona.", + "genre_instruction": "Instruction assembled from the selected writing genre.", + "lore_context": "The session-specific lore and background supplied by the writer.", + "web_search_instruction": "Requires web search for verification and current-data requests when the active provider supports it.", "context": "The ten most recent Writer and Thinkloom conversation turns." } } diff --git a/src-tauri/prompts/drafting.json b/src-tauri/prompts/drafting.json index 08abbc4..937f7e8 100644 --- a/src-tauri/prompts/drafting.json +++ b/src-tauri/prompts/drafting.json @@ -6,9 +6,10 @@ "systemPrompt": "You are Thinkloom, a focused writing collaborator. Return only proposed prose; it will be staged for review. Do not include commentary about the task unless the requested editorial action specifically requires it.", "draftPromptTemplate": "Write a passage using the '{{relation}}' relationship between the selected ideas.\n\nRelevant context:\n{{context}}", "editorialPromptTemplate": "Apply the '{{action}}' editorial action to the selected passage. Return only the proposed result.\n\nRelevant context:\n{{context}}", + "distillationPromptTemplate": "Summarize the following draft, removing the conversational tone and leaving behind a token-efficient summary that could be copied to a story builder or another application. Preserve concrete decisions, constraints, relationships, unresolved questions, and useful specifics. Do not add a conversational lead-in. Provide only the raw summary.\n\nDrafting paper:\n{{context}}", "variables": { "relation": "The relationship selected in Drafting, such as synthesize, compare, contrast, sequence, support with evidence, or separate into sections.", - "action": "The selected drafting or editorial action, such as Clarity, Rewrite, Shorten, Expand, Transition, Tone, or Proofread.", + "action": "The selected drafting or editorial action, including the dedicated distill action used by Phase 1.", "context": "The selected ideas or current passage supplied by Thinkloom." } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d49bf9..3ebe388 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,6 +105,7 @@ struct DraftingPromptConfig { system_prompt: String, draft_prompt_template: String, editorial_prompt_template: String, + distillation_prompt_template: String, } #[derive(Debug, Serialize)] @@ -189,6 +190,23 @@ fn prompt_config_dir(app: &AppHandle) -> CommandResult { }) } +fn merge_missing_prompt_fields(current: &mut Value, defaults: &Value) -> bool { + let (Some(current_object), Some(default_object)) = + (current.as_object_mut(), defaults.as_object()) + else { + return false; + }; + let mut changed = false; + for (key, default_value) in default_object { + if let Some(current_value) = current_object.get_mut(key) { + changed |= merge_missing_prompt_fields(current_value, default_value); + } else { + current_object.insert(key.clone(), default_value.clone()); + changed = true; + } + } + changed +} fn ensure_prompt_files_at(app: &AppHandle) -> CommandResult { let directory = prompt_config_dir(app)?; fs::create_dir_all(&directory).map_err(|error| { @@ -202,6 +220,32 @@ fn ensure_prompt_files_at(app: &AppHandle) -> CommandResult { let path = directory.join(name); if !path.exists() { atomic_write(&path, contents.as_bytes())?; + } else if name.ends_with(".json") { + let existing = fs::read_to_string(&path).map_err(|error| { + CommandError::io("Could not read prompt configuration for migration", error) + })?; + if let (Ok(mut current), Ok(defaults)) = ( + serde_json::from_str::(&existing), + serde_json::from_str::(contents), + ) { + let mut changed = merge_missing_prompt_fields(&mut current, &defaults); + if name == "conversation.json" { + let legacy_system = current + .get("systemPrompt") + .and_then(Value::as_str) + .filter(|prompt| !prompt.contains("{{persona_instruction}}")) + .map(str::to_owned); + if let Some(legacy_system) = legacy_system { + current["systemPrompt"] = Value::String(format!( + "{{{{persona_instruction}}}}\n\n{{{{genre_instruction}}}}\n\nProject lore and context:\n{{{{lore_context}}}}\n\n{{{{web_search_instruction}}}}\n\n{legacy_system}" + )); + changed = true; + } + } + if changed { + write_json(&path, ¤t)?; + } + } } } Ok(directory) @@ -297,7 +341,7 @@ fn prompts_for_request( })?; variables.insert("challenge_guidance".into(), guidance.clone()); Ok(( - config.system_prompt, + render_prompt_template(&config.system_prompt, &variables)?, render_prompt_template(&config.user_prompt_template, &variables)?, )) } @@ -308,6 +352,7 @@ fn prompts_for_request( || config.system_prompt.trim().is_empty() || config.draft_prompt_template.trim().is_empty() || config.editorial_prompt_template.trim().is_empty() + || config.distillation_prompt_template.trim().is_empty() { return Err(CommandError::new( "PROMPT_CONFIG_INVALID", @@ -318,10 +363,10 @@ fn prompts_for_request( true, )); } - let template = if required_prompt_variable(&variables, "action")? == "draft" { - &config.draft_prompt_template - } else { - &config.editorial_prompt_template + let template = match required_prompt_variable(&variables, "action")? { + "draft" => &config.draft_prompt_template, + "distill" => &config.distillation_prompt_template, + _ => &config.editorial_prompt_template, }; Ok(( config.system_prompt, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2ea40a3..6c6b9d4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Thinkloom", - "version": "0.4.0", + "version": "0.5.0", "identifier": "com.thinkloom.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/Thinkloom.tsx b/src/Thinkloom.tsx index 289d68e..0cf786d 100644 --- a/src/Thinkloom.tsx +++ b/src/Thinkloom.tsx @@ -1,5 +1,5 @@ "use client"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "@tiptap/markdown"; @@ -8,12 +8,14 @@ type Phase = "ideation" | "drafting" | "finalization"; type View = Phase | "project" | "history" | "provenance" | "export" | "settings"; type IdeaStatus = "suggested" | "accepted" | "rejected" | "archived"; type Actor = "user" | "assistant" | "system"; +type Persona = "Supportive Coach" | "Critical Editor" | "Creative Partner"; interface Turn { id: string; speaker: "user" | "assistant"; text: string; createdAt: string } +interface SessionSnapshot { id: string; title: string; createdAt: string; updatedAt: string; persona: Persona; genre: string; lore: string; turns: Turn[]; workspace: string; summary: 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 } +interface State { id: string; title: string; subtitle: string; phase: Phase; privacy: "Local" | "Cloud" | "Mixed"; cloudApproved: boolean; challenge: "Gentle" | "Balanced" | "Rigorous"; spokenReplies: boolean; persona: Persona; genre: string; lore: string; workspace: string; summary: string; activeSessionId: string; sessionTitle: string; sessionCreatedAt: string; sessions: SessionSnapshot[]; 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)}`; @@ -23,9 +25,22 @@ const hash = (value: string) => { let n = 2166136261; for (let i = 0; i < value. 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 personaInstructions: Record = { + "Supportive Coach": "Be an encouraging writing coach. Ask clarifying questions and help the writer expand promising ideas without taking control of them.", + "Critical Editor": "Be a rigorous critical editor. Focus on structure, logic, and clarity; respectfully identify contradictions, weak assumptions, and plot holes.", + "Creative Partner": "Be an inventive creative partner. Explore what-if scenarios, world-building possibilities, surprising connections, and unexpected but relevant turns.", +}; +const genreInstructions: Record = { + Fiction: "Support fiction development with attention to character, conflict, setting, causality, and narrative momentum.", + "Non-Fiction": "Support non-fiction development with attention to thesis, evidence, reader value, structure, and factual precision.", + Academic: "Support academic development with attention to research questions, argument structure, evidence, counterarguments, and precise claims.", + Poetry: "Support poetry development with attention to image, sound, compression, line, voice, and emotional resonance.", + Screenplay: "Support screenplay development with attention to scene objectives, visual action, dialogue, pacing, and dramatic turns.", +}; +const voiceHints: Record = { "Supportive Coach": ["Samantha", "Jenny", "Zira"], "Critical Editor": ["Daniel", "David", "Mark"], "Creative Partner": ["Ava", "Aria", "Sonia"] }; 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, + 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, persona: "Supportive Coach", genre: "Non-Fiction", lore: "A long-form essay exploring attention as a shared civic condition rather than only a matter of personal discipline.", workspace: "Attention may be a form of civic infrastructure.\n\nInstitutions shape whether sustained focus is possible.\n\nPersonal digital-minimalism advice cannot repair systems designed around extraction.", summary: "Attention should be framed as civic infrastructure: a shared condition shaped by institutional norms, incentives, architecture, and technology. The essay will contrast individual self-management with systemic responsibility, using classrooms, meetings, and public debate as examples of spaces where degraded attention creates collective costs.", activeSessionId: "session_1", sessionTitle: "Attention as civic infrastructure", sessionCreatedAt: "2026-07-16T17:42:00.000Z", sessions: [], 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" }, @@ -47,12 +62,15 @@ 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 normalizeState(value: Partial): State { + return { ...initial, ...value, persona: value.persona ?? initial.persona, genre: value.genre ?? initial.genre, lore: value.lore ?? "", workspace: value.workspace ?? "", summary: value.summary ?? "", activeSessionId: value.activeSessionId ?? uid("session"), sessionTitle: value.sessionTitle ?? "Recovered ideation session", sessionCreatedAt: value.sessionCreatedAt ?? value.updatedAt ?? now(), sessions: Array.isArray(value.sessions) ? value.sessions : [], turns: Array.isArray(value.turns) ? value.turns : [] }; +} 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, + id, title, subtitle: "", phase: "ideation", privacy: "Local", cloudApproved: false, challenge: "Balanced", spokenReplies: false, persona: "Supportive Coach", genre: "Fiction", lore: "", workspace: "", summary: "", activeSessionId: uid("session"), sessionTitle: "New ideation session", sessionCreatedAt: createdAt, sessions: [], 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}`) }], @@ -88,15 +106,20 @@ const StructuredEditor = forwardRef
; }); 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); }); }, []); + 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 [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(normalizeState(JSON.parse(raw) as Partial)); } 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 snapshotSession = (current: State): SessionSnapshot => ({ id: current.activeSessionId, title: current.sessionTitle, createdAt: current.sessionCreatedAt, updatedAt: current.updatedAt, persona: current.persona, genre: current.genre, lore: current.lore, turns: current.turns, workspace: current.workspace, summary: current.summary }); + const startNewSession = () => { const createdAt = now(); setProject((current) => ({ ...current, activeSessionId: uid("session"), sessionTitle: `Ideation session ${current.sessions.length + 2}`, sessionCreatedAt: createdAt, turns: [], workspace: "", summary: "", sessions: [...current.sessions.filter((session) => session.id !== current.activeSessionId), snapshotSession(current)], updatedAt: createdAt })); setMessage(""); setNotice("New ideation session ready. Your previous session is saved locally."); }; + const switchSession = (id: string) => { if (id === project.activeSessionId) return; const target = project.sessions.find((session) => session.id === id); if (!target) return; setProject((current) => ({ ...current, activeSessionId: target.id, sessionTitle: target.title, sessionCreatedAt: target.createdAt, persona: target.persona, genre: target.genre, lore: target.lore, turns: target.turns, workspace: target.workspace, summary: target.summary, sessions: [...current.sessions.filter((session) => session.id !== target.id && session.id !== current.activeSessionId), snapshotSession(current)], updatedAt: now() })); setMessage(""); setNotice(`Opened “${target.title}”.`); requestAnimationFrame(() => conversationEnd.current?.scrollIntoView()); }; + const appendToIdeas = (turn: Turn) => mutate("TRANSCRIPT_APPENDED_TO_DRAFT", `Appended ${turn.speaker === "user" ? "your" : "Thinkloom's"} message to the drafting paper`, (current) => ({ ...current, workspace: `${current.workspace.trimEnd()}${current.workspace.trim() ? "\n\n" : ""}${turn.text}` })); + const summarizeDraft = async () => { if (!project.workspace.trim() || busy) return; setBusy(true); setNotice("Distilling the drafting paper…"); try { const generated = await invokeNative("generate_text", { profile: project.provider, promptVariables: { action: "distill", relation: "synthesize", context: project.workspace }, cloudApproved: project.cloudApproved, purpose: "drafting" }); if (!generated?.trim()) throw new Error("The provider returned an empty response."); mutate("DRAFT_DISTILLED", "Token-efficient summary ready", (current) => ({ ...current, summary: generated.trim() }), "assistant"); } catch (error) { setNotice(`Summary could not be generated: ${nativeError(error)} Check Settings → Model provider, then retry.`); } finally { setBusy(false); } }; + const exportSession = (format: "markdown" | "text") => { const heading = format === "markdown" ? "#" : ""; const section = (title: string) => `${heading ? `${heading}${heading} ` : ""}${title}`; const transcript = project.turns.map((turn) => `${turn.speaker === "user" ? "Writer" : "Thinkloom"} · ${fmt(turn.createdAt)}\n${turn.text}`).join("\n\n"); const contents = `${heading ? `${heading} ` : ""}${project.sessionTitle}\n\n${section("Session metadata")}\nCreated: ${new Date(project.sessionCreatedAt).toLocaleString()}\nPersona: ${project.persona}\nGenre: ${project.genre}\nProvider: ${project.provider.name} (${project.provider.mode})\n\n${section("Lore & context")}\n${project.lore || "None provided."}\n\n${section("Transcript")}\n${transcript || "No conversation yet."}\n\n${section("Drafting paper")}\n${project.workspace || "No ideas gathered yet."}\n\n${section("Final summary")}\n${project.summary || "No summary generated yet."}\n`; const safeName = project.sessionTitle.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() || "thinkloom-session"; download(`${safeName}.${format === "markdown" ? "md" : "txt"}`, contents, format === "markdown" ? "text/markdown" : "text/plain"); setNotice(`Exported “${project.sessionTitle}” as ${format === "markdown" ? "Markdown" : "plain text"}.`); }; const sendMessage = async () => { const text = message.trim(); if (!text || busy) return; setMessage(""); setBusy(true); @@ -105,7 +128,7 @@ export default function Thinkloom() { 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" }); + const generated = await invokeNative("generate_text", { profile: project.provider, promptVariables: { challenge: project.challenge, persona_instruction: personaInstructions[project.persona], genre_instruction: genreInstructions[project.genre] ?? genreInstructions.Fiction, lore_context: project.lore.trim() || "No additional lore or context was provided.", web_search_instruction: "When the writer asks to verify facts, search the internet, or check current data, use an available web search tool and summarize findings naturally. Clearly say when the active provider cannot search the web.", 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 } })); @@ -114,16 +137,11 @@ export default function Thinkloom() { 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" })); + mutate("ASSISTANT_RESPONSE_GENERATED", `Response generated by ${project.provider.name}`, (current) => ({ ...current, turns: [...current.turns, assistantTurn] }), "assistant"); + if (project.spokenReplies && "speechSynthesis" in window) { const utterance = new SpeechSynthesisUtterance(reply); const hints = voiceHints[project.persona]; utterance.voice = speechSynthesis.getVoices().find((voice) => hints.some((hint) => voice.name.includes(hint))) ?? null; speechSynthesis.speak(utterance); } 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") => { @@ -169,14 +187,14 @@ export default function Thinkloom() { 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"); + setProject(next); setProjectPath(result.path); setMessage(""); 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 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(normalizeState(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…

; @@ -188,13 +206,30 @@ export default function Thinkloom() {
{view === "ideation" &&
-
Open thread

Explore the thought

{(["Gentle", "Balanced", "Rigorous"] as const).map((level) => )}
-
Today · Session 1
{project.turns.map((turn) =>
{turn.speaker === "user" ? "You" : "Thinkloom"}

{turn.text}

{turn.speaker === "user" && }
)}{busy &&
Thinkloom

}
-
{listening &&
Listening — audio stays in memory and will be discarded
}