From 21e4d249128d655896754fdb061a57b17ea06033 Mon Sep 17 00:00:00 2001 From: Yash Raghubanshi Date: Sun, 4 Oct 2026 00:55:26 +0530 Subject: [PATCH] composer: restore typed text when thread:send fails --- apps/desktop/e2e/app.spec.ts | 17 +++++ apps/desktop/src/renderer/App.tsx | 72 +++++++++++++------ .../src/renderer/components/Composer.tsx | 24 +++++-- .../src/renderer/components/DraftView.tsx | 2 +- .../src/renderer/components/ThreadView.tsx | 2 +- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/apps/desktop/e2e/app.spec.ts b/apps/desktop/e2e/app.spec.ts index 047920f5..69d1e87f 100644 --- a/apps/desktop/e2e/app.spec.ts +++ b/apps/desktop/e2e/app.spec.ts @@ -205,6 +205,23 @@ test("⇧⌘N creates a worktree thread and the header shows its branch and path await tid(page, "action-copy-path").click(); await expect(tid(page, "thread-actions")).toHaveCount(0); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(pathTitle); + + // If thread:send fails (e.g. missing worktree directory), the composer restores the typed text. + const hiddenPath = `${pathTitle}.tmp`; + fs.renameSync(pathTitle!, hiddenPath); + try { + const input = tid(page, "composer-input"); + await input.focus(); + await input.fill("Preserve this message on error"); + await page.keyboard.press("Meta+Enter"); + await expect(page.locator(".toast")).toContainText("working directory is missing"); + await expect(input).toHaveValue("Preserve this message on error"); + await page.locator(".toast button").click(); + await input.fill(""); + } finally { + fs.renameSync(hiddenPath, pathTitle!); + } + // Back to the first thread for the relaunch test. await tid(page, "thread-row").nth(1).click(); await expect(tid(page, "context-kind")).toHaveAttribute("data-kind", "local"); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index c7f84256..2fd2a117 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -212,27 +212,59 @@ export function App() { if (p) await refresh(); }); /** First send from a draft: create the thread with the draft's settings, then send. */ - const sendDraft = (text: string) => draft && act(async () => { - const d = draft; - setCreating(true); - try { - const s = d.settings; - const t = await bridge.invoke("thread:create", { projectId: d.projectId, worktree: d.worktree, backend: s.backend, mode: s.mode, model: s.model || undefined, auto: s.auto }); - if (s.plan || s.effort) await bridge.invoke("thread:update", { threadId: t.id, patch: { ...(s.plan ? { plan: true } : {}), ...(s.effort ? { effort: s.effort } : {}) } }); - await refresh(); - justCreated.current = t.id; - setDraft(null); - setSelected(t.id); - const r = await bridge.invoke("thread:send", { threadId: t.id, text }); - if (!r.ok) setError(r.error ?? "send failed"); - } finally { - setCreating(false); + const sendDraft = async (text: string): Promise => { + if (!draft) return false; + const draftKey = `draft:${draftRevision}`; + let createdId: string | null = null; + const res = await act(async () => { + const d = draft; + setCreating(true); + try { + const s = d.settings; + const t = await bridge.invoke("thread:create", { projectId: d.projectId, worktree: d.worktree, backend: s.backend, mode: s.mode, model: s.model || undefined, auto: s.auto }); + createdId = t.id; + if (s.plan || s.effort) await bridge.invoke("thread:update", { threadId: t.id, patch: { ...(s.plan ? { plan: true } : {}), ...(s.effort ? { effort: s.effort } : {}) } }); + await refresh(); + justCreated.current = t.id; + setDraft(null); + setSelected(t.id); + const r = await bridge.invoke("thread:send", { threadId: t.id, text }); + if (!r.ok) { + setError(r.error ?? "send failed"); + return false; + } + return true; + } finally { + setCreating(false); + } + }); + if (res !== true) { + if (createdId) { + setUnsentFor(createdId, text); + } else { + setUnsentFor(draftKey, text); + } + return false; } - }); - const send = (text: string) => thread && act(async () => { - const r = await bridge.invoke("thread:send", { threadId: thread.id, text }); - if (!r.ok) setError(r.error ?? "send failed"); - }); + return true; + }; + const send = async (text: string): Promise => { + if (!thread) return false; + const threadId = thread.id; + const res = await act(async () => { + const r = await bridge.invoke("thread:send", { threadId, text }); + if (!r.ok) { + setError(r.error ?? "send failed"); + return false; + } + return true; + }); + if (res !== true) { + setUnsentFor(threadId, text); + return false; + } + return true; + }; const stop = () => thread && act(() => bridge.invoke("thread:stop", { threadId: thread.id })); const answer = (itemId: string, a: "yes" | "no" | "always") => thread && act(() => bridge.invoke("thread:answer", { threadId: thread.id, itemId, answer: a })); const updateThread = (patch: ThreadPatch) => thread && act(async () => { diff --git a/apps/desktop/src/renderer/components/Composer.tsx b/apps/desktop/src/renderer/components/Composer.tsx index b14549ae..3fd1b3ae 100644 --- a/apps/desktop/src/renderer/components/Composer.tsx +++ b/apps/desktop/src/renderer/components/Composer.tsx @@ -41,7 +41,7 @@ interface Props { onModel: (m: string, effort?: string) => void; onEffort: (e: string | undefined) => void; onAuto: (auto: boolean) => void; - onSend: (text: string) => void; + onSend: (text: string) => Promise | boolean | void; onStop: () => void; /** Set by the ⌘⏎ / focus shortcuts in App. */ inputRef: React.RefObject; @@ -59,6 +59,7 @@ const MAX_INPUT = 180; export function Composer({ text, onText: setText, busy, context, backend, mode, plan, model, effort, auto, models, modelsError, suggestion, onBackend, onMode, onPlan, onModel, onEffort, onAuto, onSend, onStop, inputRef }: Props) { const fallbackRef = useRef(null); const ta = inputRef ?? fallbackRef; + const submittingRef = useRef(false); useEffect(() => { const el = ta.current; @@ -67,11 +68,24 @@ export function Composer({ text, onText: setText, busy, context, backend, mode, el.style.height = `${Math.min(MAX_INPUT, Math.max(MIN_INPUT, el.scrollHeight))}px`; }, [text]); - const submit = () => { + const submit = async () => { const t = text.trim(); - if (!t || busy) return; - onSend(t); + if (!t || busy || submittingRef.current) return; + submittingRef.current = true; + const original = text; setText(""); + try { + const ok = await onSend(t); + if (ok === false) { + setText(original); + ta.current?.focus(); + } + } catch { + setText(original); + ta.current?.focus(); + } finally { + submittingRef.current = false; + } }; // The suggestion is an offer, never a default: it fills the box only on an explicit click, Tab or → while @@ -110,7 +124,7 @@ export function Composer({ text, onText: setText, busy, context, backend, mode, } if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) { e.preventDefault(); - submit(); + void submit(); } }} spellCheck={false} diff --git a/apps/desktop/src/renderer/components/DraftView.tsx b/apps/desktop/src/renderer/components/DraftView.tsx index 85648984..08b47e7b 100644 --- a/apps/desktop/src/renderer/components/DraftView.tsx +++ b/apps/desktop/src/renderer/components/DraftView.tsx @@ -31,7 +31,7 @@ interface Props { /** The project checkout's current branch; shown for a Local draft (a worktree's branch does not exist yet). */ branch?: string; onChange: (draft: Draft) => void; - onSend: (text: string) => void; + onSend: (text: string) => Promise | boolean | void; inputRef: React.RefObject; text: string; onText: (text: string) => void; diff --git a/apps/desktop/src/renderer/components/ThreadView.tsx b/apps/desktop/src/renderer/components/ThreadView.tsx index 1612b43d..a103fbc0 100644 --- a/apps/desktop/src/renderer/components/ThreadView.tsx +++ b/apps/desktop/src/renderer/components/ThreadView.tsx @@ -12,7 +12,7 @@ interface Props { /** Unsent composer text for this thread, kept by App across thread switches. */ text: string; onText: (text: string) => void; - onSend: (text: string) => void; + onSend: (text: string) => Promise | boolean | void; onStop: () => void; onAnswer: (itemId: string, answer: "yes" | "no" | "always") => void; onUpdate: (patch: ThreadPatch) => void;