diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 84780d4bc97..af6b93b5041 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import type { ReactElement } from "react"; +import { StrictMode, type ReactElement } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -547,6 +547,18 @@ describe("TaskChatComposer", () => { expect(onAdd).toHaveBeenCalledWith("unsent draft", undefined, undefined); }); + it("preserves a restored draft through the StrictMode effect probe", () => { + localStorage.setItem(KEY, "still here"); + render( + + + , + ); + + expect(editable().textContent).toBe("still here"); + expect(localStorage.getItem(KEY)).toBe("still here"); + }); + it("saves the body to localStorage after the debounce window", () => { vi.useFakeTimers(); try { @@ -578,6 +590,20 @@ describe("TaskChatComposer", () => { } }); + it("flushes a pending draft before the page unloads", () => { + vi.useFakeTimers(); + try { + render(); + + typeText("save before reload"); + window.dispatchEvent(new Event("beforeunload")); + + expect(localStorage.getItem(KEY)).toBe("save before reload"); + } finally { + vi.useRealTimers(); + } + }); + it("clears the draft key on a successful send (not via the empty-body debounce)", async () => { localStorage.setItem(KEY, "queued message"); const onAdd = vi.fn().mockResolvedValue(undefined); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 1bd94209881..f8880b18e2b 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -156,7 +156,10 @@ export function TaskChatComposer({ mobile = false, draftKey, }: TaskChatComposerProps) { - const [body, setBody] = useState(""); + // Read the initial value during state initialization. The cleanup below runs + // during React StrictMode's effect probe, so starting with an empty body and + // restoring in an effect could erase a saved draft before that restore runs. + const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : "")); const [submitting, setSubmitting] = useState(false); const [pendingMode, setPendingMode] = useState(workMode); const [pendingAssignee, setPendingAssignee] = useState(null); @@ -192,6 +195,15 @@ export function TaskChatComposer({ }; }, [draftKey]); + // React unmount cleanup is not guaranteed to run during a page unload. Save + // synchronously so a recent keystroke does not depend on the debounce timer. + useEffect(() => { + if (!draftKey) return; + const flushDraft = () => saveDraft(draftKey, bodyRef.current); + window.addEventListener("beforeunload", flushDraft); + return () => window.removeEventListener("beforeunload", flushDraft); + }, [draftKey]); + const modeMeta = workModeMetaFor(pendingMode); const canAcceptFiles = Boolean(onAttachImage || onImageUpload); const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0); @@ -320,17 +332,17 @@ export function TaskChatComposer({ const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; setSubmitting(true); - setBody(""); try { if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } await onAdd(fullBody, reopen, reassignment); if (draftKey) clearDraft(draftKey); + setBody(""); setAttachments([]); setPendingAssignee(null); } catch { - setBody(trimmed); // restore on failure (chips stay + the draft save effect re-fires) + // Keep the body and its pending draft while the user resolves the failure. } finally { setSubmitting(false); }