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);
}