From 775790a84066c05c672417fad82271e316f0821e Mon Sep 17 00:00:00 2001 From: Oze Date: Fri, 9 Oct 2026 17:46:45 -0700 Subject: [PATCH 1/9] fix(chat): retain composer drafts until submission is accepted --- .../components/Composer/Composer.tsx | 42 +-- .../Composer/utils/submitMessage/index.ts | 1 + .../utils/submitMessage/submitMessage.test.ts | 99 +++++++ .../utils/submitMessage/submitMessage.ts | 29 ++ .../ComposerBody/ComposerBody.test.tsx | 258 ++++++++++++++++++ .../components/ComposerBody/ComposerBody.tsx | 75 +++-- .../src/components/PromptInput/types.ts | 5 +- 7 files changed, 454 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.ts create mode 100644 packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx index 98963b47822..d88fb44eea1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx @@ -6,7 +6,6 @@ import type { UserMessage, } from "@superset/chat/protocol"; import type { - ComposerChip, ComposerMentionEntry, ComposerMentionProvider, PromptInputCommand, @@ -34,7 +33,6 @@ import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/ import { AgentComposer } from "renderer/routes/_authenticated/components/AgentComposer"; import { CHAT_COLUMN_CLASSNAME, CHAT_GUTTER_CLASSNAME } from "../../constants"; import { commandChip, LEADING_COMMAND } from "../../utils/commandChip"; -import { elementsForChips } from "../../utils/messageElements"; import { type AgentSwitcher, ModelPicker } from "./components/ModelPicker"; import { ModePicker, type SessionMode } from "./components/ModePicker"; import { QueuedPrompts } from "./components/QueuedPrompts"; @@ -45,6 +43,7 @@ import { } from "./hooks/useComposerDraft"; import { useQueueActions } from "./hooks/useQueueActions"; import { useUploadAttachments } from "./hooks/useUploadAttachments"; +import { submitMessage } from "./utils/submitMessage"; export type ComposerProps = { workspaceId: string; @@ -121,7 +120,7 @@ export const Composer = memo(function Composer({ const cloudUtils = cloudTrpc.useUtils(); const uploadAttachments = useUploadAttachments(workspaceId); const attachmentPreview = useAttachmentPreview(); - const { storedDraft, onChange, clearDraft } = useComposerDraft(draftKey); + const { storedDraft, onChange } = useComposerDraft(draftKey); const promptInputRef = useRef(null); const queueActions = useQueueActions(promptQueue, promptInputRef); useImperativeHandle( @@ -264,37 +263,9 @@ export const Composer = memo(function Composer({ [availableCommands, t], ); - const send = useCallback( - async ( - text: string, - files: File[], - mentions: ComposerChip[], - steer: boolean, - ) => { - const tags = await uploadAttachments(files); - if (!tags) { - promptInputRef.current?.appendText(text); - return; - } - const elements = elementsForChips(text.trim(), mentions); - onSend( - [ - { - type: "text", - text: [text.trim(), ...tags].filter(Boolean).join("\n"), - ...(elements.length > 0 ? { elements } : {}), - }, - ], - { steer }, - ); - clearDraft(); - }, - [onSend, uploadAttachments, clearDraft], - ); - const handleSubmit = useCallback( ({ text, files, mentions, steer }: PromptInputSubmitPayload) => { - if (disabled || (text.trim() === "" && files.length === 0)) return; + if (disabled || (text.trim() === "" && files.length === 0)) return false; const name = LEADING_COMMAND.exec(text.trim())?.[1]; if ( name && @@ -304,9 +275,12 @@ export const Composer = memo(function Composer({ toast.error(t({ message: `Unknown command /${name}` })); return false; } - return send(text, files, mentions, steer); + return submitMessage( + { text, files, mentions, steer }, + { uploadAttachments, onSend }, + ); }, - [availableCommands, disabled, send, t], + [availableCommands, disabled, onSend, uploadAttachments, t], ); const queueListRef = useRef(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/index.ts new file mode 100644 index 00000000000..ee2ccccce76 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/index.ts @@ -0,0 +1 @@ +export { submitMessage } from "./submitMessage"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.test.ts new file mode 100644 index 00000000000..f21d8440026 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.test.ts @@ -0,0 +1,99 @@ +import { expect, mock, test } from "bun:test"; +import type { PromptInputSubmitPayload } from "@superset/chat-ui/PromptInput"; +import { submitMessage } from "./submitMessage"; + +const draft: PromptInputSubmitPayload = { + text: "Review the file", + files: [], + mentions: [], + steer: false, +}; + +test("failed attachment upload declines submission without sending", async () => { + const onSend = mock(); + const result = await submitMessage(draft, { + uploadAttachments: async () => null, + onSend, + }); + expect(result).toBe(false); + expect(onSend).not.toHaveBeenCalled(); +}); + +test("submission remains pending until the send callback accepts", async () => { + let accept!: () => void; + const pending = new Promise((resolve) => { + accept = resolve; + }); + let settled = false; + const result = submitMessage(draft, { + uploadAttachments: async () => [], + onSend: () => pending, + }).then(() => { + settled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + accept(); + await result; + expect(settled).toBe(true); +}); + +test("a declined send propagates false so the composer retains its draft", async () => { + expect( + await submitMessage(draft, { + uploadAttachments: async () => [], + onSend: async () => false, + }), + ).toBe(false); +}); + +test("rejected sends remain rejected so the composer does not clear its draft", async () => { + const failure = new Error("Transport unavailable"); + await expect( + submitMessage(draft, { + uploadAttachments: async () => [], + onSend: async () => { + throw failure; + }, + }), + ).rejects.toBe(failure); +}); + +test("accepted sends retain attachment tags, chip metadata and steering", async () => { + const onSend = mock(() => ({ clientId: "accepted-outbox-entry" })); + await submitMessage( + { + ...draft, + text: "Read example.ts", + mentions: [ + { + label: "Example", + serialized: "example.ts", + data: { elementKind: "file_mention" }, + }, + ], + steer: true, + }, + { + uploadAttachments: async () => ["[attachment: review.txt]"], + onSend, + }, + ); + expect(onSend).toHaveBeenCalledWith( + [ + { + type: "text", + text: "Read example.ts\n[attachment: review.txt]", + elements: [ + { + byteRange: { start: 5, end: 15 }, + elementKind: "file_mention", + label: "Example", + }, + ], + }, + ], + { steer: true }, + ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.ts new file mode 100644 index 00000000000..c276a006f4e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/utils/submitMessage/submitMessage.ts @@ -0,0 +1,29 @@ +import type { UserContent } from "@superset/chat/protocol"; +import type { PromptInputSubmitPayload } from "@superset/chat-ui/PromptInput"; +import { elementsForChips } from "../../../../utils/messageElements"; + +export async function submitMessage( + { text, files, mentions, steer }: PromptInputSubmitPayload, + { + uploadAttachments, + onSend, + }: { + uploadAttachments: (files: File[]) => Promise; + onSend: (content: UserContent[], options: { steer: boolean }) => unknown; + }, +) { + const tags = await uploadAttachments(files); + if (!tags) return false; + const elements = elementsForChips(text.trim(), mentions); + const result = await onSend( + [ + { + type: "text", + text: [text.trim(), ...tags].filter(Boolean).join("\n"), + ...(elements.length > 0 ? { elements } : {}), + }, + ], + { steer }, + ); + if (result === false) return false; +} diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx new file mode 100644 index 00000000000..d1f161b784f --- /dev/null +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx @@ -0,0 +1,258 @@ +import { afterAll, afterEach, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { LexicalEditor } from "lexical"; +import type { ComposerBodyProps } from "./ComposerBody"; + +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +mock.module("@lingui/react/macro", () => ({ + Trans: ({ children }: { children?: unknown }) => children, + useLingui: () => ({ + t: ({ message }: { message: string }) => message, + }), +})); + +const { act, cleanup, fireEvent, render } = await import( + "@testing-library/react" +); +const { useEffect } = await import("react"); +const { LexicalComposer } = await import("@lexical/react/LexicalComposer"); +const { useLexicalComposerContext } = await import( + "@lexical/react/LexicalComposerContext" +); +const { + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + KEY_ENTER_COMMAND, +} = await import("lexical"); +const { MentionChipNode } = await import("../../nodes/mentionChipNode"); +const { ComposerBody } = await import("./ComposerBody"); + +function EditorProbe({ + onEditor, +}: { + onEditor: (editor: LexicalEditor) => void; +}) { + const [editor] = useLexicalComposerContext(); + useEffect(() => onEditor(editor), [editor, onEditor]); + return null; +} + +async function setup(onSubmit: ComposerBodyProps["onSubmit"]) { + let editor: LexicalEditor | undefined; + const view = render( + { + throw error; + }, + }} + > + { + editor = value; + }} + /> + + , + ); + const currentEditor = editor; + if (!currentEditor) throw new Error("Editor did not mount"); + const write = async (text: string, chip = false) => { + await act(async () => { + currentEditor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(text)); + if (chip) + paragraph.append( + MentionChipNode.fromChip({ + label: "Example file", + serialized: "@example.ts", + data: { path: "example.ts" }, + }), + ); + $getRoot().clear().append(paragraph); + }, + { discrete: true }, + ); + }); + }; + const attach = (file: File) => { + const input = + view.container.querySelector("input[type=file]"); + if (!input) throw new Error("File input did not mount"); + fireEvent.change(input, { target: { files: [file] } }); + }; + await write("Review the file"); + return { + ...view, + editor: currentEditor, + write, + attach, + text: () => + currentEditor.getEditorState().read(() => $getRoot().getTextContent()), + submit: () => + fireEvent.click(view.getByRole("button", { name: "Send message" })), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +test("keeps the full draft and attachment while submission is pending", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise); + view.attach(new File(["fixture"], "review.txt")); + view.submit(); + await act(async () => {}); + try { + expect(view.text()).toBe("Review the file"); + expect(view.getByText("review.txt")).toBeTruthy(); + } finally { + await act(async () => pending.resolve()); + } +}); + +test("does not submit twice while acceptance is pending", async () => { + const pending = deferred(); + const onSubmit = mock(() => pending.promise); + const view = await setup(onSubmit); + view.submit(); + await view.write("Another draft"); + view.submit(); + await act(async () => pending.resolve()); + expect(onSubmit).toHaveBeenCalledTimes(1); +}); + +test("ignores another Enter key while submission is pending", async () => { + const pending = deferred(); + const onSubmit = mock(() => pending.promise); + const view = await setup(onSubmit); + view.submit(); + await view.write("Another draft"); + await act(async () => { + view.editor.dispatchCommand( + KEY_ENTER_COMMAND, + new KeyboardEvent("keydown", { key: "Enter" }), + ); + }); + await act(async () => pending.resolve()); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(view.text()).toBe("Another draft"); +}); + +test("keeps a draft when submission returns false", async () => { + const view = await setup(() => false); + view.submit(); + await act(async () => {}); + expect(view.text()).toBe("Review the file"); +}); + +test("keeps text and files when asynchronous submission declines", async () => { + const view = await setup(async () => false); + view.attach(new File(["fixture"], "review.txt")); + view.submit(); + await act(async () => {}); + expect(view.text()).toBe("Review the file"); + expect(view.getByText("review.txt")).toBeTruthy(); +}); + +test("handles rejected submission and leaves the draft available for a manual retry", async () => { + const pending = deferred(); + const onSubmit = mock(() => pending.promise); + const view = await setup(onSubmit); + view.attach(new File(["fixture"], "review.txt")); + view.submit(); + await act(async () => pending.reject(new Error("Transport unavailable"))); + expect(view.text()).toBe("Review the file"); + expect(view.getByText("review.txt")).toBeTruthy(); + expect(onSubmit).toHaveBeenCalledTimes(1); +}); + +test("clears an accepted unchanged draft and its attachments", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise); + view.attach(new File(["fixture"], "review.txt")); + view.submit(); + await act(async () => pending.resolve()); + expect(view.text()).toBe(""); + expect(view.queryByText("review.txt")).toBeNull(); +}); + +test("preserves new Lexical chips, text and files after an earlier send succeeds", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise); + view.attach(new File(["old"], "review.txt")); + view.submit(); + await view.write("Next task ", true); + view.attach(new File(["new"], "next.txt")); + await act(async () => pending.resolve()); + expect(view.text()).toBe("Next task @example.ts"); + expect(view.getByText("Example file")).toBeTruthy(); + expect(view.getByText("next.txt")).toBeTruthy(); + expect(view.queryByText("review.txt")).toBeNull(); +}); + +test("preserves a draft edited back to its original text while pending", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise); + view.submit(); + await view.write("Different draft"); + await view.write("Review the file"); + await act(async () => pending.resolve()); + expect(view.text()).toBe("Review the file"); +}); + +test("selection changes do not keep an otherwise accepted unchanged draft", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise); + view.submit(); + await act(async () => { + view.editor.update(() => $getRoot().selectStart(), { discrete: true }); + }); + await act(async () => pending.resolve()); + expect(view.text()).toBe(""); +}); + +test("flushes a pending Lexical edit before deciding whether to clear it", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise); + view.submit(); + await act(async () => { + pending.resolve(); + view.editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); + paragraph.append($createTextNode(" with more context")); + }); + }); + expect(view.text()).toBe("Review the file with more context"); +}); diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx index 3fabb280324..82d731fbbf3 100644 --- a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx @@ -223,6 +223,9 @@ export function ComposerBody({ [editor, focusAtEnd], ); const [attachments, setAttachments] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const submitting = useRef(false); + const draftRevision = useRef(0); const [isEmpty, setIsEmpty] = useState(true); const [dragging, setDragging] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); @@ -300,10 +303,15 @@ export function ComposerBody({ onChangeRef.current = onChange; useEffect( () => - editor.registerUpdateListener(({ editorState }) => { - const text = editorState.read(() => $getRoot().getTextContent()); - onChangeRef.current?.(text); - }), + editor.registerUpdateListener( + ({ editorState, dirtyElements, dirtyLeaves }) => { + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { + draftRevision.current += 1; + } + const text = editorState.read(() => $getRoot().getTextContent()); + onChangeRef.current?.(text); + }, + ), [editor], ); @@ -435,7 +443,8 @@ export function ComposerBody({ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); }; - const submit = ({ steer = false }: { steer?: boolean } = {}) => { + const submit = async ({ steer = false }: { steer?: boolean } = {}) => { + if (submitting.current) return; if ( stateRef.current.status === "streaming" && !stateRef.current.submitWhileStreaming @@ -451,16 +460,43 @@ export function ComposerBody({ if (!text && files.length === 0 && !stateRef.current.allowEmptySubmit) { return; } - if (stateRef.current.onSubmit?.({ text, files, mentions, steer }) === false) - return; - historyNavigationRef.current?.reset(); - if (!stateRef.current.clearOnSubmit) return; - editor.update(() => $getRoot().clear()); - setAttachments((previous) => { - for (const attachment of previous) releaseAttachment(attachment); - return []; - }); - setPanel(null); + const submittedRevision = draftRevision.current; + const submittedAttachments = new Set( + stateRef.current.attachments.map((attachment) => attachment.id), + ); + submitting.current = true; + setIsSubmitting(true); + try { + if ( + (await stateRef.current.onSubmit?.({ + text, + files, + mentions, + steer, + })) === false + ) { + return; + } + historyNavigationRef.current?.reset(); + if (!stateRef.current.clearOnSubmit) return; + // A delayed acceptance must not erase a newer draft or its Lexical chips. + if (editor.read(() => draftRevision.current) === submittedRevision) { + editor.update(() => $getRoot().clear()); + } + setAttachments((previous) => + previous.filter((attachment) => { + if (!submittedAttachments.has(attachment.id)) return true; + releaseAttachment(attachment); + return false; + }), + ); + setPanel(null); + } catch { + // The caller owns error feedback. Keep the editor intact for manual retry. + } finally { + submitting.current = false; + setIsSubmitting(false); + } }; const submitRef = useRef(submit); submitRef.current = submit; @@ -494,7 +530,7 @@ export function ComposerBody({ if (event?.shiftKey || event?.isComposing || event?.keyCode === 229) return false; event?.preventDefault(); - submitRef.current({ + void submitRef.current({ steer: Boolean(event?.metaKey || event?.ctrlKey), }); return true; @@ -1012,10 +1048,11 @@ export function ComposerBody({ aria-label={t({ message: "Send message", })} - disabled={!canSend} - onClick={() => submit()} + disabled={!canSend || isSubmitting} + aria-busy={isSubmitting} + onClick={() => void submit()} className={ - canSend + canSend && !isSubmitting ? cn( FOOTER_BUTTON_CLASS, "cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90", diff --git a/packages/chat-ui/src/components/PromptInput/types.ts b/packages/chat-ui/src/components/PromptInput/types.ts index 6b23ce512b1..3b49afc3939 100644 --- a/packages/chat-ui/src/components/PromptInput/types.ts +++ b/packages/chat-ui/src/components/PromptInput/types.ts @@ -128,10 +128,11 @@ export type PromptInputProps = { // Spans of a stored draft that were chips before it was serialized, so // they come back as chips. Runs on the draft as read, never on typing. findChips?: (text: string) => ComposerChipMatch[]; - /** Returning `false` keeps the draft in the editor. */ + /** Clears only after acceptance. Returning `false` or rejecting keeps the draft. */ onSubmit?: ( payload: PromptInputSubmitPayload, - ) => false | void | Promise; + // biome-ignore lint/suspicious/noConfusingVoidType: existing async handlers resolve void; false explicitly declines submission. + ) => false | void | Promise; onStop?: () => void; header?: ReactNode; onAddFiles?: (files: File[]) => void; From 96d673db9ae005fb4c82c9933aede913def9514c Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 16:09:47 -0700 Subject: [PATCH 2/9] fix(chat-ui): retain history-saved drafts during pending sends --- .../ComposerBody/ComposerBody.test.tsx | 27 ++++++++++++++++++- .../components/ComposerBody/ComposerBody.tsx | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx index d1f161b784f..2a333567e78 100644 --- a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx @@ -29,6 +29,8 @@ const { $createTextNode, $getRoot, $isElementNode, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, } = await import("lexical"); const { MentionChipNode } = await import("../../nodes/mentionChipNode"); @@ -44,7 +46,10 @@ function EditorProbe({ return null; } -async function setup(onSubmit: ComposerBodyProps["onSubmit"]) { +async function setup( + onSubmit: ComposerBodyProps["onSubmit"], + { history }: Pick = {}, +) { let editor: LexicalEditor | undefined; const view = render( , @@ -231,6 +237,25 @@ test("preserves a draft edited back to its original text while pending", async ( expect(view.text()).toBe("Review the file"); }); +test("preserves the newer draft saved by history navigation while a send is pending", async () => { + const pending = deferred(); + const view = await setup(() => pending.promise, { + history: ["Earlier question"], + }); + view.submit(); + await view.write("Next task draft"); + await act(async () => { + view.editor.update(() => $getRoot().selectStart(), { discrete: true }); + view.editor.dispatchCommand(KEY_ARROW_UP_COMMAND, null); + }); + expect(view.text()).toBe("Earlier question"); + await act(async () => pending.resolve()); + await act(async () => { + view.editor.dispatchCommand(KEY_ARROW_DOWN_COMMAND, null); + }); + expect(view.text()).toBe("Next task draft"); +}); + test("selection changes do not keep an otherwise accepted unchanged draft", async () => { const pending = deferred(); const view = await setup(() => pending.promise); diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx index 82d731fbbf3..11994a0fc87 100644 --- a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx @@ -477,10 +477,10 @@ export function ComposerBody({ ) { return; } - historyNavigationRef.current?.reset(); if (!stateRef.current.clearOnSubmit) return; // A delayed acceptance must not erase a newer draft or its Lexical chips. if (editor.read(() => draftRevision.current) === submittedRevision) { + historyNavigationRef.current?.reset(); editor.update(() => $getRoot().clear()); } setAttachments((previous) => From 77e8e6f35e5532213fe30f291e0804075a839d2c Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 16:12:36 -0700 Subject: [PATCH 3/9] fix(desktop): settle submitted drafts after composer unmount --- .../components/Composer/Composer.tsx | 29 +++- .../useComposerDraft.test.tsx | 142 ++++++++++++++++++ .../useComposerDraft/useComposerDraft.ts | 29 +++- 3 files changed, 190 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx index d88fb44eea1..a506daffd36 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx @@ -120,7 +120,7 @@ export const Composer = memo(function Composer({ const cloudUtils = cloudTrpc.useUtils(); const uploadAttachments = useUploadAttachments(workspaceId); const attachmentPreview = useAttachmentPreview(); - const { storedDraft, onChange } = useComposerDraft(draftKey); + const { storedDraft, onChange, beginSubmission } = useComposerDraft(draftKey); const promptInputRef = useRef(null); const queueActions = useQueueActions(promptQueue, promptInputRef); useImperativeHandle( @@ -264,7 +264,7 @@ export const Composer = memo(function Composer({ ); const handleSubmit = useCallback( - ({ text, files, mentions, steer }: PromptInputSubmitPayload) => { + async ({ text, files, mentions, steer }: PromptInputSubmitPayload) => { if (disabled || (text.trim() === "" && files.length === 0)) return false; const name = LEADING_COMMAND.exec(text.trim())?.[1]; if ( @@ -275,12 +275,27 @@ export const Composer = memo(function Composer({ toast.error(t({ message: `Unknown command /${name}` })); return false; } - return submitMessage( - { text, files, mentions, steer }, - { uploadAttachments, onSend }, - ); + const finishSubmission = beginSubmission(text); + try { + const result = await submitMessage( + { text, files, mentions, steer }, + { uploadAttachments, onSend }, + ); + finishSubmission(result !== false); + return result; + } catch (error) { + finishSubmission(false); + throw error; + } }, - [availableCommands, disabled, onSend, uploadAttachments, t], + [ + availableCommands, + disabled, + onSend, + uploadAttachments, + t, + beginSubmission, + ], ); const queueListRef = useRef(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx index a525bdb424f..bf470e084a9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx @@ -71,3 +71,145 @@ test("text recovered after a composer read its draft is handed to it once", () = expect(reader.result.current.storedDraft).toBe("unsent message"); expect(takeRecoveredDraftText("chat-v3-draft:early")).toBeNull(); }); + +test("an accepted upload/send after unmount clears the submitted persisted draft", async () => { + const { submitMessage } = await import("../../utils/submitMessage"); + const key = "chat-v3-draft:accepted-after-unmount"; + const composer = renderHook(() => useComposerDraft(key)); + act(() => composer.result.current.onChange(" submitted text ")); + const upload = Promise.withResolvers(); + const finishSubmission = + composer.result.current.beginSubmission("submitted text"); + const pending = submitMessage( + { text: "submitted text", files: [], mentions: [], steer: false }, + { uploadAttachments: () => upload.promise, onSend: async () => true }, + ); + composer.unmount(); + upload.resolve([]); + finishSubmission((await pending) !== false); + expect(window.localStorage.getItem(key)).toBeNull(); + const remounted = renderHook(() => useComposerDraft(key)); + expect(remounted.result.current.storedDraft).toBeUndefined(); +}); + +test("rejected or thrown submissions preserve the unmounted draft", async () => { + const { submitMessage } = await import("../../utils/submitMessage"); + for (const rejection of [false, new Error("not accepted")]) { + const key = `chat-v3-draft:rejected-${String(rejection)}`; + const composer = renderHook(() => useComposerDraft(key)); + act(() => composer.result.current.onChange("keep this")); + const finish = composer.result.current.beginSubmission("keep this"); + composer.unmount(); + try { + const result = await submitMessage( + { text: "keep this", files: [], mentions: [], steer: false }, + { + uploadAttachments: async () => [], + onSend: async () => { + if (rejection instanceof Error) throw rejection; + return rejection; + }, + }, + ); + finish(result !== false); + } catch { + finish(false); + } + expect(window.localStorage.getItem(key)).toBe("keep this"); + } +}); + +test("a raw edit and undo invalidate a pending submission", () => { + const key = "chat-v3-draft:submission-aba"; + const composer = renderHook(() => useComposerDraft(key)); + act(() => composer.result.current.onChange(" same text ")); + const finish = composer.result.current.beginSubmission("same text"); + act(() => composer.result.current.onChange("same text")); + act(() => composer.result.current.onChange(" same text ")); + composer.unmount(); + finish(true); + expect(window.localStorage.getItem(key)).toBe(" same text "); +}); + +test("recovered text invalidates a pending submission", () => { + const key = "chat-v3-draft:submission-recovery"; + const composer = renderHook(() => useComposerDraft(key)); + act(() => composer.result.current.onChange("submitted")); + const finish = composer.result.current.beginSubmission("submitted"); + prependToDraft(key, "recovered queued message"); + composer.unmount(); + finish(true); + expect(window.localStorage.getItem(key)).toBe( + "recovered queued message\n\nsubmitted", + ); +}); + +test("a different mount of the same draft can edit while a submission settles", () => { + const key = "chat-v3-draft:submission-other-mount"; + const first = renderHook(() => useComposerDraft(key)); + act(() => first.result.current.onChange("submitted")); + const finish = first.result.current.beginSubmission("submitted"); + const second = renderHook(() => useComposerDraft(key)); + act(() => second.result.current.onChange("newer draft")); + first.unmount(); + finish(true); + second.unmount(); + expect(window.localStorage.getItem(key)).toBe("newer draft"); +}); + +test("a newer same-key submission replaces the older receipt", () => { + const key = "chat-v3-draft:submission-newer"; + const first = renderHook(() => useComposerDraft(key)); + act(() => first.result.current.onChange("submitted")); + const older = first.result.current.beginSubmission("submitted"); + const second = renderHook(() => useComposerDraft(key)); + const newer = second.result.current.beginSubmission("submitted"); + first.unmount(); + second.unmount(); + older(true); + expect(window.localStorage.getItem(key)).toBe("submitted"); + newer(true); + expect(window.localStorage.getItem(key)).toBeNull(); +}); + +test("acceptance retains an empty memory fallback when storage removal fails", () => { + const key = "chat-v3-draft:submission-remove-failure"; + const composer = renderHook(() => useComposerDraft(key)); + act(() => composer.result.current.onChange("submitted")); + const finish = composer.result.current.beginSubmission("submitted"); + composer.unmount(); + const removeItem = spyOn( + window.localStorage, + "removeItem", + ).mockImplementation(() => { + throw new Error("SecurityError"); + }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + finish(true); + const next = renderHook(() => useComposerDraft(key)); + expect(next.result.current.storedDraft).toBe(""); + act(() => next.result.current.onChange("newer draft")); + next.unmount(); + expect(window.localStorage.getItem(key)).toBe("newer draft"); + } finally { + removeItem.mockRestore(); + warn.mockRestore(); + } +}); + +test("a submission keeps its original draft key across navigation", () => { + const firstKey = "chat-v3-draft:submission-navigation-a"; + const secondKey = "chat-v3-draft:submission-navigation-b"; + const composer = renderHook(({ key }) => useComposerDraft(key), { + initialProps: { key: firstKey }, + }); + act(() => composer.result.current.onChange("submitted")); + const finish = composer.result.current.beginSubmission("submitted"); + composer.rerender({ key: secondKey }); + act(() => composer.result.current.onChange("next chat draft")); + finish(true); + composer.unmount(); + expect(window.localStorage.getItem(firstKey)).toBeNull(); + expect(window.localStorage.getItem(secondKey)).toBe("next chat draft"); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts index b9171c658de..bde227e4720 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts @@ -4,6 +4,7 @@ const DRAFT_DEBOUNCE_MS = 300; const unsavedDrafts = new Map(); const recoveredAfterRead = new Map(); +const pendingSubmissions = new Map(); function persistDraft(draftKey: string, text: string): boolean { try { @@ -27,6 +28,7 @@ function readDraft(draftKey: string): string | undefined { } export function prependToDraft(draftKey: string, text: string) { + pendingSubmissions.delete(draftKey); const current = readDraft(draftKey); const merged = current ? `${text}\n\n${current}` : text; unsavedDrafts.set(draftKey, merged); @@ -59,6 +61,9 @@ export function useComposerDraft(draftKey: string) { }, []); const onChange = useCallback( (text: string) => { + const submission = pendingSubmissions.get(draftKey); + if (submission && submission.text !== text) + pendingSubmissions.delete(draftKey); if (draftTimer.current) clearTimeout(draftTimer.current); unsavedDrafts.set(draftKey, text); pendingDraftKey.current = draftKey; @@ -73,17 +78,35 @@ export function useComposerDraft(draftKey: string) { return { draftKey, text: readDraft(draftKey) }; }); + const beginSubmission = useCallback( + (text: string) => { + pendingSubmissions.delete(draftKey); + const submitted = { text: readDraft(draftKey) ?? "" }; + if (submitted.text.trim() !== text.trim()) return () => {}; + pendingSubmissions.set(draftKey, submitted); + return (accepted: boolean) => { + if (pendingSubmissions.get(draftKey) !== submitted) return; + pendingSubmissions.delete(draftKey); + if (!accepted || (readDraft(draftKey) ?? "") !== submitted.text) return; + unsavedDrafts.set(draftKey, ""); + if (persistDraft(draftKey, "")) unsavedDrafts.delete(draftKey); + }; + }, + [draftKey], + ); + const clearDraft = useCallback(() => { + pendingSubmissions.delete(draftKey); if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = null; pendingDraftKey.current = null; - unsavedDrafts.delete(draftKey); - persistDraft(draftKey, ""); + unsavedDrafts.set(draftKey, ""); + if (persistDraft(draftKey, "")) unsavedDrafts.delete(draftKey); setSeed({ draftKey, text: undefined }); }, [draftKey]); const storedDraft = seed.draftKey === draftKey ? seed.text : readDraft(draftKey); - return { storedDraft, onChange, clearDraft }; + return { storedDraft, onChange, clearDraft, beginSubmission }; } From 7b088605e874e5d1c4cff1ed963ef537e674e070 Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 16:15:31 -0700 Subject: [PATCH 4/9] fix(desktop): avoid restoring an accepted composer draft seed --- .../useComposerDraft.test.tsx | 30 +++++++++++++++++++ .../useComposerDraft/useComposerDraft.ts | 3 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx index bf470e084a9..94e0a1ee663 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx @@ -213,3 +213,33 @@ test("a submission keeps its original draft key across navigation", () => { expect(window.localStorage.getItem(firstKey)).toBeNull(); expect(window.localStorage.getItem(secondKey)).toBe("next chat draft"); }); + +test("acceptance clears the mounted seed before navigating back to the same draft", () => { + const key = "chat-v3-draft:submission-seeded-navigation"; + window.localStorage.setItem(key, "submitted"); + const composer = renderHook(({ key }) => useComposerDraft(key), { + initialProps: { key }, + }); + const finish = composer.result.current.beginSubmission("submitted"); + act(() => finish(true)); + composer.rerender({ + key: "chat-v3-draft:submission-seeded-navigation-other", + }); + composer.rerender({ key }); + expect(composer.result.current.storedDraft).toBeUndefined(); +}); + +test("acceptance cannot restore the seed of a replacement mount", () => { + const key = "chat-v3-draft:submission-replacement-seed"; + window.localStorage.setItem(key, "submitted"); + const first = renderHook(() => useComposerDraft(key)); + const finish = first.result.current.beginSubmission("submitted"); + const replacement = renderHook(({ key }) => useComposerDraft(key), { + initialProps: { key }, + }); + first.unmount(); + finish(true); + replacement.rerender({ key: "chat-v3-draft:submission-replacement-other" }); + replacement.rerender({ key }); + expect(replacement.result.current.storedDraft).toBeUndefined(); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts index bde227e4720..cd37b8fd1a1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts @@ -105,8 +105,9 @@ export function useComposerDraft(draftKey: string) { setSeed({ draftKey, text: undefined }); }, [draftKey]); + const currentDraft = readDraft(draftKey); const storedDraft = - seed.draftKey === draftKey ? seed.text : readDraft(draftKey); + currentDraft && seed.draftKey === draftKey ? seed.text : currentDraft; return { storedDraft, onChange, clearDraft, beginSubmission }; } From a24899d81f2e5dd33f465af4844d0973a92ca3d5 Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 16:18:57 -0700 Subject: [PATCH 5/9] fix(desktop): synchronize cleared seeds across mounted composers --- .../useComposerDraft.test.tsx | 32 +++++++++++++- .../useComposerDraft/useComposerDraft.ts | 42 +++++++++++++++---- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx index 94e0a1ee663..672408c9f5d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx @@ -238,8 +238,38 @@ test("acceptance cannot restore the seed of a replacement mount", () => { initialProps: { key }, }); first.unmount(); - finish(true); + act(() => finish(true)); replacement.rerender({ key: "chat-v3-draft:submission-replacement-other" }); replacement.rerender({ key }); expect(replacement.result.current.storedDraft).toBeUndefined(); }); + +test("a temporary storage read failure keeps the mounted draft seed", () => { + const key = "chat-v3-draft:seed-read-failure"; + window.localStorage.setItem(key, "keep this draft"); + const composer = renderHook(() => useComposerDraft(key)); + const getItem = spyOn(window.localStorage, "getItem").mockImplementation( + () => { + throw new Error("SecurityError"); + }, + ); + try { + composer.rerender(); + expect(composer.result.current.storedDraft).toBe("keep this draft"); + } finally { + getItem.mockRestore(); + } +}); + +test("acceptance while navigated away clears the original cached seed", () => { + const key = "chat-v3-draft:submission-away-seed"; + window.localStorage.setItem(key, "submitted"); + const composer = renderHook(({ key }) => useComposerDraft(key), { + initialProps: { key }, + }); + const finish = composer.result.current.beginSubmission("submitted"); + composer.rerender({ key: "chat-v3-draft:submission-away-other" }); + finish(true); + composer.rerender({ key }); + expect(composer.result.current.storedDraft).toBeUndefined(); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts index cd37b8fd1a1..b2fd48c83a3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts @@ -5,6 +5,7 @@ const DRAFT_DEBOUNCE_MS = 300; const unsavedDrafts = new Map(); const recoveredAfterRead = new Map(); const pendingSubmissions = new Map(); +const clearedDraftListeners = new Map void>>(); function persistDraft(draftKey: string, text: string): boolean { try { @@ -17,13 +18,19 @@ function persistDraft(draftKey: string, text: string): boolean { } } -function readDraft(draftKey: string): string | undefined { +function clearStoredDraft(draftKey: string) { + unsavedDrafts.set(draftKey, ""); + if (persistDraft(draftKey, "")) unsavedDrafts.delete(draftKey); + for (const notify of clearedDraftListeners.get(draftKey) ?? []) notify(); +} + +function readDraft(draftKey: string, fallback?: string): string | undefined { const unsaved = unsavedDrafts.get(draftKey); if (unsaved !== undefined) return unsaved; try { return window.localStorage.getItem(draftKey) ?? undefined; } catch { - return undefined; + return fallback; } } @@ -78,6 +85,27 @@ export function useComposerDraft(draftKey: string) { return { draftKey, text: readDraft(draftKey) }; }); + useEffect(() => { + setSeed((current) => { + const text = readDraft( + draftKey, + current.draftKey === draftKey ? current.text : undefined, + ); + return current.draftKey === draftKey && current.text === text + ? current + : { draftKey, text }; + }); + const listeners = + clearedDraftListeners.get(draftKey) ?? new Set<() => void>(); + const clearSeed = () => setSeed({ draftKey, text: undefined }); + listeners.add(clearSeed); + clearedDraftListeners.set(draftKey, listeners); + return () => { + listeners.delete(clearSeed); + if (listeners.size === 0) clearedDraftListeners.delete(draftKey); + }; + }, [draftKey]); + const beginSubmission = useCallback( (text: string) => { pendingSubmissions.delete(draftKey); @@ -88,8 +116,7 @@ export function useComposerDraft(draftKey: string) { if (pendingSubmissions.get(draftKey) !== submitted) return; pendingSubmissions.delete(draftKey); if (!accepted || (readDraft(draftKey) ?? "") !== submitted.text) return; - unsavedDrafts.set(draftKey, ""); - if (persistDraft(draftKey, "")) unsavedDrafts.delete(draftKey); + clearStoredDraft(draftKey); }; }, [draftKey], @@ -100,14 +127,11 @@ export function useComposerDraft(draftKey: string) { if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = null; pendingDraftKey.current = null; - unsavedDrafts.set(draftKey, ""); - if (persistDraft(draftKey, "")) unsavedDrafts.delete(draftKey); - setSeed({ draftKey, text: undefined }); + clearStoredDraft(draftKey); }, [draftKey]); - const currentDraft = readDraft(draftKey); const storedDraft = - currentDraft && seed.draftKey === draftKey ? seed.text : currentDraft; + seed.draftKey === draftKey ? seed.text : readDraft(draftKey); return { storedDraft, onChange, clearDraft, beginSubmission }; } From c942bf98e010f2586d2ef1a7a9aa87e7b9ac9c8e Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 16:19:09 -0700 Subject: [PATCH 6/9] test(chat-ui): set explicit caret positions for draft history regression --- .../ComposerBody/ComposerBody.test.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx index 2a333567e78..c1a0315899d 100644 --- a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx @@ -245,13 +245,24 @@ test("preserves the newer draft saved by history navigation while a send is pend view.submit(); await view.write("Next task draft"); await act(async () => { - view.editor.update(() => $getRoot().selectStart(), { discrete: true }); - view.editor.dispatchCommand(KEY_ARROW_UP_COMMAND, null); + view.editor.update( + () => { + $getRoot().selectStart(); + view.editor.dispatchCommand(KEY_ARROW_UP_COMMAND, null); + }, + { discrete: true }, + ); }); expect(view.text()).toBe("Earlier question"); await act(async () => pending.resolve()); await act(async () => { - view.editor.dispatchCommand(KEY_ARROW_DOWN_COMMAND, null); + view.editor.update( + () => { + $getRoot().selectEnd(); + view.editor.dispatchCommand(KEY_ARROW_DOWN_COMMAND, null); + }, + { discrete: true }, + ); }); expect(view.text()).toBe("Next task draft"); }); From 6d1ded07659dd543d147b47b833934ba05695b87 Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 21:50:49 -0700 Subject: [PATCH 7/9] fix(desktop): share the editor draft receipt with persisted clearing Use the same flushed Lexical content revision for editor and stored draft acceptance. A late plugin catalog can hydrate chips without changing serialized text; closing before acceptance must retain that revised draft. Keep selection-only updates and older submit callers compatible. --- .../components/Composer/Composer.tsx | 10 +++- .../useComposerDraft.test.tsx | 57 ++++++++++++++++++- .../useComposerDraft/useComposerDraft.ts | 9 ++- .../ComposerBody/ComposerBody.test.tsx | 32 +++++++++++ .../components/ComposerBody/ComposerBody.tsx | 5 +- .../src/components/PromptInput/types.ts | 1 + 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx index a506daffd36..0b0bc726034 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx @@ -264,7 +264,13 @@ export const Composer = memo(function Composer({ ); const handleSubmit = useCallback( - async ({ text, files, mentions, steer }: PromptInputSubmitPayload) => { + async ({ + text, + files, + mentions, + steer, + isDraftUnchanged, + }: PromptInputSubmitPayload) => { if (disabled || (text.trim() === "" && files.length === 0)) return false; const name = LEADING_COMMAND.exec(text.trim())?.[1]; if ( @@ -275,7 +281,7 @@ export const Composer = memo(function Composer({ toast.error(t({ message: `Unknown command /${name}` })); return false; } - const finishSubmission = beginSubmission(text); + const finishSubmission = beginSubmission(text, isDraftUnchanged); try { const result = await submitMessage( { text, files, mentions, steer }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx index 672408c9f5d..122eaa0a941 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx @@ -1,6 +1,8 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { act, cleanup, fireEvent, render, renderHook } = await import( + "@testing-library/react" +); const { prependToDraft, takeRecoveredDraftText, useComposerDraft } = await import("./useComposerDraft"); @@ -273,3 +275,56 @@ test("acceptance while navigated away clears the original cached seed", () => { composer.rerender({ key }); expect(composer.result.current.storedDraft).toBeUndefined(); }); + +test("a late plugin chip stays saved when a send is accepted after close", async () => { + const { PromptInput } = await import("@superset/chat-ui/PromptInput"); + type Props = import("@superset/chat-ui/PromptInput").PromptInputProps; + const key = "chat-v3-draft:late-plugin-hydration"; + const text = "Review @example.ts"; + window.localStorage.setItem(key, text); + const accepted = Promise.withResolvers(); + function DraftComposer({ findChips }: Pick) { + const { storedDraft, onChange, beginSubmission } = useComposerDraft(key); + return ( + { + const finish = beginSubmission( + payload.text, + payload.isDraftUnchanged, + ); + await accepted.promise; + finish(true); + }} + /> + ); + } + const composer = render(); + await act(async () => {}); + fireEvent.click(composer.getByRole("button", { name: "Send message" })); + composer.rerender( + [ + { + start: 7, + end: text.length, + chip: { label: "Example file", serialized: "@example.ts" }, + }, + ]} + />, + ); + await act(async () => {}); + expect(composer.getByText("Example file")).toBeTruthy(); + expect(composer.getByRole("textbox").textContent).toBe("Review Example file"); + composer.unmount(); + await act(async () => accepted.resolve()); + expect(window.localStorage.getItem(key)).toBe(text); + const reopened = render(); + await act(async () => {}); + expect(reopened.getByRole("textbox").textContent).toBe(text); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts index b2fd48c83a3..9673420fd8c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts @@ -107,7 +107,7 @@ export function useComposerDraft(draftKey: string) { }, [draftKey]); const beginSubmission = useCallback( - (text: string) => { + (text: string, isDraftUnchanged?: () => boolean) => { pendingSubmissions.delete(draftKey); const submitted = { text: readDraft(draftKey) ?? "" }; if (submitted.text.trim() !== text.trim()) return () => {}; @@ -115,7 +115,12 @@ export function useComposerDraft(draftKey: string) { return (accepted: boolean) => { if (pendingSubmissions.get(draftKey) !== submitted) return; pendingSubmissions.delete(draftKey); - if (!accepted || (readDraft(draftKey) ?? "") !== submitted.text) return; + if ( + !accepted || + isDraftUnchanged?.() === false || + (readDraft(draftKey) ?? "") !== submitted.text + ) + return; clearStoredDraft(draftKey); }; }, diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx index c1a0315899d..7a46b6fc964 100644 --- a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.test.tsx @@ -292,3 +292,35 @@ test("flushes a pending Lexical edit before deciding whether to clear it", async }); expect(view.text()).toBe("Review the file with more context"); }); + +test("submission exposes the flushed content revision used for draft clearing", async () => { + const pending = deferred(); + let isDraftUnchanged: (() => boolean) | undefined; + const view = await setup((payload) => { + isDraftUnchanged = payload.isDraftUnchanged; + return pending.promise; + }); + await view.write("@example.ts"); + view.submit(); + expect(isDraftUnchanged?.()).toBe(true); + await act(async () => { + view.editor.update(() => $getRoot().selectStart(), { discrete: true }); + }); + expect(isDraftUnchanged?.()).toBe(true); + await act(async () => { + view.editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); + paragraph.clear().append( + MentionChipNode.fromChip({ + label: "Example file", + serialized: "@example.ts", + }), + ); + }); + expect(isDraftUnchanged?.()).toBe(false); + }); + await act(async () => pending.resolve()); + expect(view.text()).toBe("@example.ts"); + expect(view.getByText("Example file")).toBeTruthy(); +}); diff --git a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx index 11994a0fc87..3b0438c6bd3 100644 --- a/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx +++ b/packages/chat-ui/src/components/PromptInput/components/ComposerBody/ComposerBody.tsx @@ -461,6 +461,8 @@ export function ComposerBody({ return; } const submittedRevision = draftRevision.current; + const isDraftUnchanged = () => + editor.read(() => draftRevision.current === submittedRevision); const submittedAttachments = new Set( stateRef.current.attachments.map((attachment) => attachment.id), ); @@ -469,6 +471,7 @@ export function ComposerBody({ try { if ( (await stateRef.current.onSubmit?.({ + isDraftUnchanged, text, files, mentions, @@ -479,7 +482,7 @@ export function ComposerBody({ } if (!stateRef.current.clearOnSubmit) return; // A delayed acceptance must not erase a newer draft or its Lexical chips. - if (editor.read(() => draftRevision.current) === submittedRevision) { + if (isDraftUnchanged()) { historyNavigationRef.current?.reset(); editor.update(() => $getRoot().clear()); } diff --git a/packages/chat-ui/src/components/PromptInput/types.ts b/packages/chat-ui/src/components/PromptInput/types.ts index 3b49afc3939..f30c8e394c5 100644 --- a/packages/chat-ui/src/components/PromptInput/types.ts +++ b/packages/chat-ui/src/components/PromptInput/types.ts @@ -84,6 +84,7 @@ export type PromptInputAttachment = { }; export type PromptInputSubmitPayload = { + isDraftUnchanged?: () => boolean; text: string; files: File[]; mentions: ComposerChip[]; From 28bb0dd6ddbbfb2b297c065c45a2890efcbf17fa Mon Sep 17 00:00:00 2001 From: Oze Date: Tue, 13 Oct 2026 22:42:39 -0700 Subject: [PATCH 8/9] fix(desktop): guard pending sends across composer remounts Keep the per-draft in-flight token until the original send settles, independently of draft cleanup receipt invalidation. Decline duplicate sends before uploading or sending. Preserve newer drafts. Regressions cover remounts and chip hydration, edits and recovery, key switches, and retry after rejection. --- .../components/Composer/Composer.tsx | 1 + .../useComposerDraft.test.tsx | 305 +++++++++++++++--- .../useComposerDraft/useComposerDraft.ts | 9 +- 3 files changed, 266 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx index 0b0bc726034..22b71d9f92a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/Composer.tsx @@ -282,6 +282,7 @@ export const Composer = memo(function Composer({ return false; } const finishSubmission = beginSubmission(text, isDraftUnchanged); + if (!finishSubmission) return false; try { const result = await submitMessage( { text, files, mentions, steer }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx index 122eaa0a941..4638e7b5445 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.test.tsx @@ -6,6 +6,51 @@ const { act, cleanup, fireEvent, render, renderHook } = await import( const { prependToDraft, takeRecoveredDraftText, useComposerDraft } = await import("./useComposerDraft"); +function requireSubmission( + submission: ReturnType< + ReturnType["beginSubmission"] + >, +) { + if (!submission) throw new Error("Expected the submission to start"); + return submission; +} + +const { PromptInput } = await import("@superset/chat-ui/PromptInput"); +type PromptProps = import("@superset/chat-ui/PromptInput").PromptInputProps; + +function PendingDraftComposer({ + draftKey, + findChips, + onSend, +}: Pick & { + draftKey: string; + onSend: () => Promise; +}) { + const { storedDraft, onChange, beginSubmission } = useComposerDraft(draftKey); + return ( + { + const finish = beginSubmission(payload.text, payload.isDraftUnchanged); + if (!finish) return false; + try { + const result = await onSend(); + finish(result); + return result === false ? false : undefined; + } catch (error) { + finish(false); + throw error; + } + }} + /> + ); +} + beforeEach(() => window.localStorage.clear()); afterEach(cleanup); test("a composer mounted before the save delay reads text typed into the one it replaces", () => { @@ -80,8 +125,9 @@ test("an accepted upload/send after unmount clears the submitted persisted draft const composer = renderHook(() => useComposerDraft(key)); act(() => composer.result.current.onChange(" submitted text ")); const upload = Promise.withResolvers(); - const finishSubmission = - composer.result.current.beginSubmission("submitted text"); + const finishSubmission = requireSubmission( + composer.result.current.beginSubmission("submitted text"), + ); const pending = submitMessage( { text: "submitted text", files: [], mentions: [], steer: false }, { uploadAttachments: () => upload.promise, onSend: async () => true }, @@ -100,7 +146,9 @@ test("rejected or thrown submissions preserve the unmounted draft", async () => const key = `chat-v3-draft:rejected-${String(rejection)}`; const composer = renderHook(() => useComposerDraft(key)); act(() => composer.result.current.onChange("keep this")); - const finish = composer.result.current.beginSubmission("keep this"); + const finish = requireSubmission( + composer.result.current.beginSubmission("keep this"), + ); composer.unmount(); try { const result = await submitMessage( @@ -125,7 +173,9 @@ test("a raw edit and undo invalidate a pending submission", () => { const key = "chat-v3-draft:submission-aba"; const composer = renderHook(() => useComposerDraft(key)); act(() => composer.result.current.onChange(" same text ")); - const finish = composer.result.current.beginSubmission("same text"); + const finish = requireSubmission( + composer.result.current.beginSubmission("same text"), + ); act(() => composer.result.current.onChange("same text")); act(() => composer.result.current.onChange(" same text ")); composer.unmount(); @@ -137,7 +187,9 @@ test("recovered text invalidates a pending submission", () => { const key = "chat-v3-draft:submission-recovery"; const composer = renderHook(() => useComposerDraft(key)); act(() => composer.result.current.onChange("submitted")); - const finish = composer.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + composer.result.current.beginSubmission("submitted"), + ); prependToDraft(key, "recovered queued message"); composer.unmount(); finish(true); @@ -150,7 +202,9 @@ test("a different mount of the same draft can edit while a submission settles", const key = "chat-v3-draft:submission-other-mount"; const first = renderHook(() => useComposerDraft(key)); act(() => first.result.current.onChange("submitted")); - const finish = first.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + first.result.current.beginSubmission("submitted"), + ); const second = renderHook(() => useComposerDraft(key)); act(() => second.result.current.onChange("newer draft")); first.unmount(); @@ -159,26 +213,31 @@ test("a different mount of the same draft can edit while a submission settles", expect(window.localStorage.getItem(key)).toBe("newer draft"); }); -test("a newer same-key submission replaces the older receipt", () => { +test("a same-key remount cannot replace an in-flight submission", () => { const key = "chat-v3-draft:submission-newer"; const first = renderHook(() => useComposerDraft(key)); act(() => first.result.current.onChange("submitted")); - const older = first.result.current.beginSubmission("submitted"); - const second = renderHook(() => useComposerDraft(key)); - const newer = second.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + first.result.current.beginSubmission("submitted"), + ); first.unmount(); - second.unmount(); - older(true); - expect(window.localStorage.getItem(key)).toBe("submitted"); - newer(true); + const replacement = renderHook(() => useComposerDraft(key)); + try { + expect(replacement.result.current.beginSubmission("submitted")).toBeNull(); + } finally { + act(() => finish(true)); + } expect(window.localStorage.getItem(key)).toBeNull(); + expect(replacement.result.current.storedDraft).toBeUndefined(); }); test("acceptance retains an empty memory fallback when storage removal fails", () => { const key = "chat-v3-draft:submission-remove-failure"; const composer = renderHook(() => useComposerDraft(key)); act(() => composer.result.current.onChange("submitted")); - const finish = composer.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + composer.result.current.beginSubmission("submitted"), + ); composer.unmount(); const removeItem = spyOn( window.localStorage, @@ -207,7 +266,9 @@ test("a submission keeps its original draft key across navigation", () => { initialProps: { key: firstKey }, }); act(() => composer.result.current.onChange("submitted")); - const finish = composer.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + composer.result.current.beginSubmission("submitted"), + ); composer.rerender({ key: secondKey }); act(() => composer.result.current.onChange("next chat draft")); finish(true); @@ -222,7 +283,9 @@ test("acceptance clears the mounted seed before navigating back to the same draf const composer = renderHook(({ key }) => useComposerDraft(key), { initialProps: { key }, }); - const finish = composer.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + composer.result.current.beginSubmission("submitted"), + ); act(() => finish(true)); composer.rerender({ key: "chat-v3-draft:submission-seeded-navigation-other", @@ -235,7 +298,9 @@ test("acceptance cannot restore the seed of a replacement mount", () => { const key = "chat-v3-draft:submission-replacement-seed"; window.localStorage.setItem(key, "submitted"); const first = renderHook(() => useComposerDraft(key)); - const finish = first.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + first.result.current.beginSubmission("submitted"), + ); const replacement = renderHook(({ key }) => useComposerDraft(key), { initialProps: { key }, }); @@ -269,7 +334,9 @@ test("acceptance while navigated away clears the original cached seed", () => { const composer = renderHook(({ key }) => useComposerDraft(key), { initialProps: { key }, }); - const finish = composer.result.current.beginSubmission("submitted"); + const finish = requireSubmission( + composer.result.current.beginSubmission("submitted"), + ); composer.rerender({ key: "chat-v3-draft:submission-away-other" }); finish(true); composer.rerender({ key }); @@ -277,38 +344,20 @@ test("acceptance while navigated away clears the original cached seed", () => { }); test("a late plugin chip stays saved when a send is accepted after close", async () => { - const { PromptInput } = await import("@superset/chat-ui/PromptInput"); - type Props = import("@superset/chat-ui/PromptInput").PromptInputProps; const key = "chat-v3-draft:late-plugin-hydration"; const text = "Review @example.ts"; window.localStorage.setItem(key, text); - const accepted = Promise.withResolvers(); - function DraftComposer({ findChips }: Pick) { - const { storedDraft, onChange, beginSubmission } = useComposerDraft(key); - return ( - { - const finish = beginSubmission( - payload.text, - payload.isDraftUnchanged, - ); - await accepted.promise; - finish(true); - }} - /> - ); - } - const composer = render(); + const accepted = Promise.withResolvers(); + const onSend = () => accepted.promise; + const composer = render( + , + ); await act(async () => {}); fireEvent.click(composer.getByRole("button", { name: "Send message" })); composer.rerender( - [ { start: 7, @@ -319,12 +368,174 @@ test("a late plugin chip stays saved when a send is accepted after close", async />, ); await act(async () => {}); + expect(composer.getByText("Example file")).toBeTruthy(); expect(composer.getByRole("textbox").textContent).toBe("Review Example file"); composer.unmount(); - await act(async () => accepted.resolve()); + await act(async () => accepted.resolve(true)); expect(window.localStorage.getItem(key)).toBe(text); - const reopened = render(); + const reopened = render( + , + ); await act(async () => {}); + expect(reopened.getByRole("textbox").textContent).toBe(text); }); + +test("draft edits, clearing and recovery do not release an in-flight send", () => { + for (const change of ["edit", "clear", "recover"]) { + const key = `chat-v3-draft:pending-${change}`; + const first = renderHook(() => useComposerDraft(key)); + act(() => first.result.current.onChange("submitted")); + const finish = requireSubmission( + first.result.current.beginSubmission("submitted"), + ); + act(() => { + if (change === "edit") first.result.current.onChange("newer draft"); + if (change === "clear") first.result.current.clearDraft(); + if (change === "recover") prependToDraft(key, "recovered"); + }); + first.unmount(); + const replacement = renderHook(() => useComposerDraft(key)); + try { + expect( + replacement.result.current.beginSubmission("next send"), + ).toBeNull(); + } finally { + finish(false); + } + const retry = replacement.result.current.beginSubmission("next send"); + expect(retry).toBeFunction(); + retry?.(false); + replacement.unmount(); + } +}); + +test("in-flight guards stay with their draft key across navigation", () => { + const key = "chat-v3-draft:pending-navigation"; + const composer = renderHook(({ draftKey }) => useComposerDraft(draftKey), { + initialProps: { draftKey: key }, + }); + act(() => composer.result.current.onChange("first draft")); + const finishFirst = requireSubmission( + composer.result.current.beginSubmission("first draft"), + ); + composer.rerender({ draftKey: `${key}-other` }); + act(() => composer.result.current.onChange("second draft")); + const finishOther = requireSubmission( + composer.result.current.beginSubmission("second draft"), + ); + expect(finishOther).toBeFunction(); + composer.rerender({ draftKey: key }); + try { + expect(composer.result.current.beginSubmission("first draft")).toBeNull(); + } finally { + act(() => finishFirst(true)); + act(() => finishOther?.(false)); + } + const retry = composer.result.current.beginSubmission("another message"); + expect(retry).toBeFunction(); + retry?.(false); + composer.unmount(); + expect(window.localStorage.getItem(key)).toBeNull(); + expect(window.localStorage.getItem(`${key}-other`)).toBe("second draft"); +}); + +test("a remounted chip-edited composer declines duplicate sends until acceptance", async () => { + const key = "chat-v3-draft:pending-remount-chip"; + const text = "Review @example.ts"; + window.localStorage.setItem(key, text); + const accepted = Promise.withResolvers(); + let sendCount = 0; + const onSend = () => { + sendCount += 1; + return accepted.promise; + }; + const original = render( + , + ); + await act(async () => {}); + fireEvent.click(original.getByRole("button", { name: "Send message" })); + original.rerender( + [ + { + start: 7, + end: text.length, + chip: { label: "Example file", serialized: "@example.ts" }, + }, + ]} + />, + ); + await act(async () => {}); + expect(original.getByText("Example file")).toBeTruthy(); + original.unmount(); + const replacement = render( + , + ); + await act(async () => {}); + try { + await act(async () => + fireEvent.click( + replacement.getByRole("button", { name: "Send message" }), + ), + ); + expect(sendCount).toBe(1); + } finally { + await act(async () => accepted.resolve(true)); + } + expect(window.localStorage.getItem(key)).toBe(text); + expect(replacement.getByRole("textbox").textContent).toBe(text); + await act(async () => + fireEvent.click(replacement.getByRole("button", { name: "Send message" })), + ); + expect(sendCount).toBe(2); + replacement.unmount(); + expect(window.localStorage.getItem(key)).toBeNull(); +}); + +test.each([ + false, + new Error("not accepted"), +])("a remounted composer can retry after a pending send settles with %s", async (rejection) => { + const key = `chat-v3-draft:pending-remount-rejected-${String(rejection)}`; + window.localStorage.setItem(key, "retry this draft"); + const pending = Promise.withResolvers(); + let sendCount = 0; + const onSend = () => { + sendCount += 1; + return pending.promise; + }; + const original = render( + , + ); + await act(async () => {}); + fireEvent.click(original.getByRole("button", { name: "Send message" })); + original.unmount(); + const replacement = render( + , + ); + await act(async () => {}); + try { + await act(async () => + fireEvent.click( + replacement.getByRole("button", { name: "Send message" }), + ), + ); + expect(sendCount).toBe(1); + } finally { + await act(async () => { + if (rejection instanceof Error) pending.reject(rejection); + else pending.resolve(false); + }); + } + expect(replacement.getByRole("textbox").textContent).toBe("retry this draft"); + await act(async () => + fireEvent.click(replacement.getByRole("button", { name: "Send message" })), + ); + expect(sendCount).toBe(2); + replacement.unmount(); + expect(window.localStorage.getItem(key)).toBe("retry this draft"); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts index 9673420fd8c..3b908bc9227 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/Composer/hooks/useComposerDraft/useComposerDraft.ts @@ -5,6 +5,7 @@ const DRAFT_DEBOUNCE_MS = 300; const unsavedDrafts = new Map(); const recoveredAfterRead = new Map(); const pendingSubmissions = new Map(); +const inFlightSubmissions = new Map(); const clearedDraftListeners = new Map void>>(); function persistDraft(draftKey: string, text: string): boolean { @@ -108,11 +109,15 @@ export function useComposerDraft(draftKey: string) { const beginSubmission = useCallback( (text: string, isDraftUnchanged?: () => boolean) => { + if (inFlightSubmissions.has(draftKey)) return null; pendingSubmissions.delete(draftKey); const submitted = { text: readDraft(draftKey) ?? "" }; - if (submitted.text.trim() !== text.trim()) return () => {}; - pendingSubmissions.set(draftKey, submitted); + inFlightSubmissions.set(draftKey, submitted); + if (submitted.text.trim() === text.trim()) + pendingSubmissions.set(draftKey, submitted); return (accepted: boolean) => { + if (inFlightSubmissions.get(draftKey) !== submitted) return; + inFlightSubmissions.delete(draftKey); if (pendingSubmissions.get(draftKey) !== submitted) return; pendingSubmissions.delete(draftKey); if ( From 6b1e4e5b8b3a6858c35eb2b06f1f370f7a64f607 Mon Sep 17 00:00:00 2001 From: Oze Date: Sun, 25 Oct 2026 17:48:06 -0700 Subject: [PATCH 9/9] fix(chat): acknowledge child steering without waiting for turn completion --- .../src/harness/acp/acpAdapter.test.ts | 208 ++++++++++++++++++ .../src/harness/acp/acpAdapter.ts | 34 ++- 2 files changed, 241 insertions(+), 1 deletion(-) diff --git a/packages/chat-runtime/src/harness/acp/acpAdapter.test.ts b/packages/chat-runtime/src/harness/acp/acpAdapter.test.ts index 0c8b6f3e47a..3c94ab131e2 100644 --- a/packages/chat-runtime/src/harness/acp/acpAdapter.test.ts +++ b/packages/chat-runtime/src/harness/acp/acpAdapter.test.ts @@ -4010,6 +4010,214 @@ describe("AcpAdapter on protocol v2", () => { await adapter.dispose(); }); + it.each([ + "injected", + "startedNewTurn", + ])("acknowledges a v1 child steer as %s without a second prompt", async (outcome) => { + const agent = new FakeAcpAgent(); + agent.protocolVersion = 1; + agent.steeringOutcome = outcome; + const { adapter } = startAdapter(agent); + await flush(); + agent.notify("sess-1", announce("child-1")); + await flush(); + try { + expect( + await adapter.steerSubagent("subagent:child-1", [ + { type: "text", text: "check the tests too" }, + ]), + ).toBe(true); + expect( + agent.sent.filter((frame) => frame.method === "_session/steering"), + ).toMatchObject([ + { + params: { + sessionId: "child-1", + prompt: [{ type: "text", text: "check the tests too" }], + _meta: { steering: { idleBehavior: "promptRequired" } }, + }, + }, + ]); + expect( + agent.sent.some((frame) => frame.method === "session/prompt"), + ).toBe(false); + } finally { + await adapter.dispose(); + } + }); + + it.each([ + "promptRequired", + "unsupported", + ])("handles the v1 child steer outcome %s without duplicating accepted input", async (outcome) => { + const agent = new FakeAcpAgent(); + agent.protocolVersion = 1; + agent.steeringOutcome = outcome; + const { adapter } = startAdapter(agent); + await flush(); + agent.notify("sess-1", announce("child-1")); + await flush(); + try { + expect( + await adapter.steerSubagent("subagent:child-1", [ + { type: "text", text: "continue" }, + ]), + ).toBe(outcome === "promptRequired"); + const requests = agent.sent.filter((frame) => + ["_session/steering", "session/prompt"].includes( + String(frame.method), + ), + ); + expect(requests.map((frame) => frame.method)).toEqual( + outcome === "promptRequired" + ? ["_session/steering", "session/prompt"] + : ["_session/steering"], + ); + } finally { + await adapter.dispose(); + } + }); + + it.each([ + 1, 2, + ])("keeps the normal child prompt path for protocol %s without v1 steering", async (version) => { + const agent = new FakeAcpAgent(); + agent.protocolVersion = version; + agent.steeringOutcome = version === 2 ? "injected" : null; + const { adapter } = startAdapter(agent); + await flush(); + agent.notify("sess-1", announce("child-1")); + await flush(); + try { + expect( + await adapter.steerSubagent("subagent:child-1", [ + { type: "text", text: "continue" }, + ]), + ).toBe(true); + expect( + agent.sent.some((frame) => frame.method === "_session/steering"), + ).toBe(false); + expect( + agent.sent.filter((frame) => frame.method === "session/prompt"), + ).toHaveLength(1); + } finally { + await adapter.dispose(); + } + }); + + it("a child closed while its attachment resolves cannot receive steering", async () => { + const agent = new FakeAcpAgent(); + agent.protocolVersion = 1; + agent.steeringOutcome = "injected"; + let release!: (value: { + path: string; + mimeType: string; + data: string; + }) => void; + let attachmentStarted!: () => void; + const started = new Promise((resolve) => { + attachmentStarted = resolve; + }); + const adapter = new AcpAdapter({ + command: "fake", + createTransport: (_opts, handlers) => agent.transport(handlers), + resolveAttachment: () => + new Promise((resolve) => { + release = resolve; + attachmentStarted(); + }), + now: () => 1, + mintId: () => "fixture-id", + }); + void collect(adapter.start({ cwd: "/work" }), []); + await flush(); + agent.notify("sess-1", announce("child-1")); + await flush(); + try { + const pending = adapter.steerSubagent("subagent:child-1", [ + { + type: "attachment", + attachmentId: "fixture", + name: "fixture.png", + mimeType: "image/png", + }, + ]); + await started; + agent.notify("sess-1", { + sessionUpdate: "subagent_update", + sessionId: "child-1", + state: { state: "idle", stopReason: "end_turn" }, + }); + await flush(); + release({ + path: "/fixture/image.png", + mimeType: "image/png", + data: "aW1hZ2U=", + }); + await expect(pending).resolves.toBe(false); + expect( + agent.sent.filter((frame) => + ["_session/steering", "session/prompt"].includes( + String(frame.method), + ), + ), + ).toEqual([]); + } finally { + await adapter.dispose(); + } + }); + + it.each([ + "promptRequired", + "injected", + ])("Stop before the v1 child %s reply prevents only new input", async (outcome) => { + const agent = new FakeAcpAgent(); + agent.protocolVersion = 1; + agent.steeringOutcome = outcome; + let responseSeen!: () => void; + let releaseResponse!: () => void; + const held = new Promise((resolve) => { + responseSeen = resolve; + }); + const adapter = new AcpAdapter({ + command: "fake", + now: () => 1, + mintId: () => "fixture-id", + createTransport: (_opts, handlers) => + agent.transport({ + ...handlers, + onLine(line) { + const frame = JSON.parse(line); + if (frame.result?.outcome === outcome) { + releaseResponse = () => handlers.onLine(line); + responseSeen(); + } else handlers.onLine(line); + }, + }), + }); + void collect(adapter.start({ cwd: "/work" }), []); + await flush(); + agent.notify("sess-1", announce("child-1")); + await flush(); + try { + const pending = adapter.steerSubagent("subagent:child-1", [ + { type: "text", text: "continue" }, + ]); + await held; + expect(await adapter.stopBackgroundTask("subagent:child-1")).toBe(true); + releaseResponse(); + await expect(pending).resolves.toBe(outcome === "injected"); + expect( + agent.sent.filter((frame) => frame.method === "session/prompt"), + ).toEqual([]); + expect( + agent.sent.filter((frame) => frame.method === "session/cancel"), + ).toHaveLength(1); + } finally { + await adapter.dispose(); + } + }); + it("renders a steer echo in the child", async () => { const agent = new FakeAcpAgent(); const { adapter, events } = startAdapter(agent); diff --git a/packages/chat-runtime/src/harness/acp/acpAdapter.ts b/packages/chat-runtime/src/harness/acp/acpAdapter.ts index d4bcac6c2d8..2eb81e748eb 100644 --- a/packages/chat-runtime/src/harness/acp/acpAdapter.ts +++ b/packages/chat-runtime/src/harness/acp/acpAdapter.ts @@ -237,6 +237,7 @@ type SubagentRun = { parentSessionId: string; parentTaskId?: string; steers: Set<{ echoes: Set; shared: boolean }>; + cancelGeneration: number; }; type SubagentControls = { @@ -701,6 +702,7 @@ export class AcpAdapter implements HarnessAdapter { const run = subagentSessionId && this.subagents.get(subagentSessionId); if (subagentSessionId) { if (!run || run.item.canStop !== true) return false; + run.cancelGeneration++; this.client.notify("session/cancel", { sessionId: subagentSessionId }); this.cancelSubagentApprovals(subagentSessionId, run); return true; @@ -723,13 +725,42 @@ export class AcpAdapter implements HarnessAdapter { const run = subagentSessionId && this.subagents.get(subagentSessionId); if (!client || !subagentSessionId || !run || run.item.canSteer !== true) return false; + const cancelGeneration = run.cancelGeneration; + const prompt = await this.toAcpPrompt(content); + if ( + this.disposed || + this.dead || + this.client !== client || + this.subagents.get(subagentSessionId) !== run || + run.cancelGeneration !== cancelGeneration || + run.item.canSteer !== true + ) + return false; const steer = { echoes: new Set(), shared: run.steers.size > 0 }; for (const other of run.steers) other.shared = true; run.steers.add(steer); try { + if (this.negotiatedVersion < 2 && this.supportsSteering) { + const response = await client.request(ACP_STEERING_METHOD, { + sessionId: subagentSessionId, + prompt, + _meta: { steering: { idleBehavior: "promptRequired" } }, + }); + const outcome = + acpSteeringResponseSchema.safeParse(response).data?.outcome; + if (outcome === "injected" || outcome === "startedNewTurn") return true; + if (outcome !== "promptRequired") return false; + if ( + this.client !== client || + this.subagents.get(subagentSessionId) !== run || + run.cancelGeneration !== cancelGeneration || + run.item.canSteer !== true + ) + return false; + } await client.request("session/prompt", { sessionId: subagentSessionId, - prompt: await this.toAcpPrompt(content), + prompt, }); return true; } catch (error) { @@ -1702,6 +1733,7 @@ export class AcpAdapter implements HarnessAdapter { parentSessionId, ...(parent ? { parentTaskId: parent.item.id } : {}), steers: new Set(), + cancelGeneration: 0, }; this.subagents.set(sessionId, run); this.emitItem(item, turnId);