From 6bf3f8ecb7c42c6acd7b6bd8f0bfe98ed4e32d1e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 13:52:05 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20tests:=20an=20open=20edit=20?= =?UTF-8?q?stays=20out=20of=20the=20shared=20draft=20(failing=20first)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds T1-T23 (32 cases) for keeping an open edit's text and files in the composer's memory (#5672, #5571), a strictMode option for renderApp and createAppHarness, and the changed existing edit tests (typeIntoEdit, D3). Replaces the two #5501 edit put-back repros in composerDraftsFormalRepro. Production code is unchanged in this commit: most new tests fail on main. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- .../ChatInput/useComposerDraft.test.ts | 28 + .../ui/chat/composerDraftsFormalRepro.test.ts | 145 +- tests/ui/chat/editKeepsUnsentDraft.test.ts | 1245 ++++++++++++++++- tests/ui/harness/createAppHarness.ts | 4 +- tests/ui/renderReviewPanel.tsx | 6 +- 5 files changed, 1246 insertions(+), 182 deletions(-) diff --git a/src/browser/features/ChatInput/useComposerDraft.test.ts b/src/browser/features/ChatInput/useComposerDraft.test.ts index 96ba16795b5..fe63d6a0783 100644 --- a/src/browser/features/ChatInput/useComposerDraft.test.ts +++ b/src/browser/features/ChatInput/useComposerDraft.test.ts @@ -78,4 +78,32 @@ describe("useComposerDraft", () => { expect(result.current.attachments.map(({ id }) => id)).toEqual(["a", "b"]); }); + + // T18: an in-flight send or command owns a detached snapshot; it never touches the live edit. + test("a snapshot append leaves the edit buffer alone; consuming the buffer appends it once", () => { + const workspaceId = "ws-edit-buffer"; + const { result } = renderDraft(workspaceId); + act(() => { + result.current.setInput("unsent"); + result.current.setAttachments([attachment("unsent")]); + result.current.beginEditDraft({ id: "edit-1", text: "edit", attachments: [attachment("e")] }); + }); + act(() => { + result.current.appendSnapshotToDraft({ text: "snapshot", attachments: [attachment("s")] }); + }); + + const view = () => getDraftStore().getView({ kind: "workspace", workspaceId }); + expect(view().text).toBe("unsent\n\nsnapshot"); + expect(view().attachments.map(({ id }) => id)).toEqual(["unsent", "s"]); + expect(result.current.editDraft?.text).toBe("edit"); + expect(result.current.editDraft?.attachments.map(({ id }) => id)).toEqual(["e"]); + + act(() => { + result.current.consumeEditDraftIntoDraft(); + result.current.consumeEditDraftIntoDraft(); + }); + expect(view().text).toBe("unsent\n\nsnapshot\n\nedit"); + expect(view().attachments.map(({ id }) => id)).toEqual(["unsent", "s", "e"]); + expect(result.current.editDraft).toBeNull(); + }); }); diff --git a/tests/ui/chat/composerDraftsFormalRepro.test.ts b/tests/ui/chat/composerDraftsFormalRepro.test.ts index 83c2dfd4d78..db05c9eb579 100644 --- a/tests/ui/chat/composerDraftsFormalRepro.test.ts +++ b/tests/ui/chat/composerDraftsFormalRepro.test.ts @@ -22,7 +22,7 @@ jest.mock("lottie-react", () => ({ __esModule: true, default: () => null, })); -import { fireEvent, waitFor } from "@testing-library/react"; +import { waitFor } from "@testing-library/react"; import * as fs from "fs/promises"; import * as path from "path"; @@ -199,142 +199,7 @@ describe("formal/composer-drafts: composer text across a failed send", () => { }, 120_000); }); -/** - * #5501: an edit send (the one send kind without a pending-send entry) still clears the composer - * and puts what it took back when it fails. Edits write the shared workspace draft (#5571), so - * another window that still holds the edit can save it again while the send is in flight. - */ -describe("formal/composer-drafts: a failed edit's put-back", () => { - beforeAll(async () => { - await preloadTestModules(); - }); - - async function startEdit(app: AppHarness, scope: DraftScope) { - await app.chat.send("first message"); - await app.chat.expectTranscriptContains("Mock response: first message", WAIT.timeout); - await app.chat.expectStreamComplete(); - const editButton = await waitFor(() => { - const button = app.view.container.querySelector('button[aria-label="Edit"]'); - if (!button) throw new Error("Edit button not found"); - return button; - }, WAIT); - fireEvent.click(editButton); - const textarea = await waitFor(() => { - const element = app.view.container.querySelector( - 'textarea[aria-label="Edit message"]' - ); - if (!element) throw new Error("Edit textarea not found"); - expect(element.value).toBe("first message"); - return element; - }, WAIT); - getDraftStore().setText(scope, "edited message"); - await waitFor(() => expect(textarea.value).toBe("edited message"), WAIT); - return textarea; - } - - async function expectRefused(app: AppHarness) { - await waitFor( - () => expect(app.view.container.textContent ?? "").toContain("formal repro: send refused"), - WAIT - ); - } - - test("does not show the edit's text twice when another window saved it meanwhile", async () => { - const app = await createAppHarness({ branchPrefix: "formal-edit-restore-dup" }); - const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - const textarea = await startEdit(app, scope).catch(async (error: unknown) => { - await app.dispose(); - throw error; - }); - const held = holdSendReplies(app, () => refused()); - try { - fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(held.spy).toHaveBeenCalledTimes(1), WAIT); - await waitFor(() => expect(getDraftStore().getView(scope).text).toBe(""), WAIT); - await getDraftStore().flush(scope); - - // A second window that still held the edit saves it again, with text typed after it. - const saved = "edited message\n\ntyped in another window"; - await app.env.services.draftService.update({ scope, text: saved }); - await waitFor(() => expect(getDraftStore().getView(scope).text).toBe(saved), WAIT); - - held.release(); - await expectRefused(app); - // Target assertion: the text the composer already holds is not put back a second time, - // and the other window's text stays. - expect(getDraftStore().getView(scope).text).toBe(saved); - await getDraftStore().flush(scope); - expect((await app.env.services.draftService.get(scope)).text).toBe(saved); - } finally { - await held.settle(); - held.spy.mockRestore(); - await app.dispose(); - } - }, 120_000); - - test("keeps the staged copy of an attachment another window saved again as pending", async () => { - const app = await createAppHarness({ branchPrefix: "formal-edit-restore-staged" }); - const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - const textarea = await startEdit(app, scope).catch(async (error: unknown) => { - await app.dispose(); - throw error; - }); - const held = holdSendReplies(app, () => refused()); - try { - const pendingFile = { - kind: "pending-file" as const, - id: "file-1", - mediaType: "text/plain", - filename: "notes.txt", - sizeBytes: 2, - dataBase64: "aGk=", - }; - getDraftStore().setAttachments(scope, [pendingFile]); - await waitFor(() => expect(getDraftStore().getView(scope).attachments).toHaveLength(1), WAIT); - fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(held.spy).toHaveBeenCalledTimes(1), WAIT); - // The send staged the file under the same id, then took it out of the composer. - await waitFor(() => expect(getDraftStore().getView(scope).attachments).toEqual([]), WAIT); - await getDraftStore().flush(scope); - - // A second window that still held the pending version saves it again, with a new file. - const newFile = { - kind: "provider" as const, - id: "file-2", - url: "data:text/plain;base64,bmV3", - mediaType: "text/plain", - filename: "new.txt", - }; - await app.env.services.draftService.update({ scope, attachments: [pendingFile, newFile] }); - await waitFor( - () => - expect( - getDraftStore() - .getView(scope) - .attachments.map(({ kind }) => kind) - ).toEqual(["pending-file", "provider"]), - WAIT - ); - - held.release(); - await expectRefused(app); - // Target assertion: the refused send puts back what it sent (the staged file), so a retry - // does not stage the file again and orphan the first copy. The new file stays. - const restored = getDraftStore().getView(scope).attachments; - expect(restored.map(({ id, kind }) => `${id}:${kind}`)).toEqual([ - "file-1:staged", - "file-2:provider", - ]); - await getDraftStore().flush(scope); - const savedAttachments = (await app.env.services.draftService.get(scope)).attachments; - expect(savedAttachments.map(({ id, kind }) => `${id}:${kind}`)).toEqual([ - "file-1:staged", - "file-2:provider", - ]); - } finally { - await held.settle(); - held.spy.mockRestore(); - await app.dispose(); - } - }, 120_000); -}); +// #5501's edit put-back repros (another window saving the edit again while its send was in +// flight) cannot happen any more: an open edit never reaches the shared draft. Their flows are +// covered in editKeepsUnsentDraft.test.ts ("a second window keeps the unsent draft while the +// first edits" and "a refused edit send reopens the edit with its text and file"). diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index ccbb9241ba2..56d29a42876 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -1,7 +1,7 @@ /** - * Editing an older message snapshots the unsent composer draft and fills the composer with the - * message. Cancel restores the snapshot; completing the edit must restore it too (#5155), or the - * draft (text and attachments) is gone, and with backend drafts its draft.json with it. + * Editing an older message fills the composer with the message. The edit's text and files live + * only in the composer's memory: the unsent draft stays in the shared, persisted draft store, so + * Cancel, a completed edit (#5155), a reload (#5672) and a second window (#5571) all keep it. */ import "../dom"; jest.mock("lottie-react", () => ({ @@ -11,15 +11,32 @@ jest.mock("lottie-react", () => ({ import { act, fireEvent, waitFor, within } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { getDraftStore } from "@/browser/stores/DraftStore"; +import { DraftStore, getDraftStore } from "@/browser/stores/DraftStore"; +import { getReviewStateStore } from "@/browser/stores/ReviewStateStore"; +import { + WorkspaceStore, + useWorkspaceStoreRaw, + workspaceStore, + type TranscriptRefreshOutcome, +} from "@/browser/stores/WorkspaceStore"; +import { createTestApiClient } from "@/browser/testUtils"; +import * as chatCommands from "@/browser/utils/chatCommands"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; +import type { FilePart } from "@/common/orpc/types"; import type { ReviewNoteData } from "@/common/types/review"; -import { EDIT_HISTORY_CHANGED_MESSAGE } from "@/constants/transcriptBarrier"; +import { joinDraftText } from "@/common/utils/composerDraftText"; +import { + EDIT_HISTORY_CHANGED_MESSAGE, + EDIT_RETRY_REFRESH_LABEL, +} from "@/constants/transcriptBarrier"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { Err } from "@/common/types/result"; +import { detectDefaultTrunkBranch } from "@/node/git"; +import { generateBranchName } from "../../ipc/helpers"; import { preloadTestModules } from "../../ipc/setup"; -import { createAppHarness, type AppHarness } from "../harness"; +import { ChatHarness, createAppHarness, type AppHarness } from "../harness"; const LOAD_TOLERANT_WAIT = { timeout: 30_000 }; @@ -72,6 +89,14 @@ async function expectUnsentDraftKept(app: AppHarness, scope: DraftScope) { expect(saved.attachments.map(({ id }) => id)).toEqual(["file-unsent"]); } +/** + * Type into the open edit textarea. The edit text lives in the composer's memory, not in the + * draft store, so it is set through the textarea as a user would. + */ +function typeIntoEdit(textarea: HTMLTextAreaElement, text: string) { + fireEvent.change(textarea, { target: { value: text } }); +} + describe("Completing an edit of an older message", () => { beforeAll(async () => { await preloadTestModules(); @@ -83,7 +108,7 @@ describe("Completing an edit of an older message", () => { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; const editTextarea = await startEditWithUnsentDraft(app, scope); - getDraftStore().setText(scope, "edited message"); + typeIntoEdit(editTextarea, "edited message"); await waitFor(() => expect(editTextarea.value).toBe("edited message")); fireEvent.keyDown(editTextarea, { key: "Enter" }); @@ -117,7 +142,7 @@ describe("Completing an edit of an older message", () => { return result; }); - getDraftStore().setText(scope, "edited message"); + typeIntoEdit(editTextarea, "edited message"); await waitFor(() => expect(editTextarea.value).toBe("edited message")); fireEvent.keyDown(editTextarea, { key: "Enter" }); await app.chat.expectTranscriptContains("edited message", LOAD_TOLERANT_WAIT.timeout); @@ -137,7 +162,7 @@ describe("Completing an edit of an older message", () => { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; const editTextarea = await startEditWithUnsentDraft(app, scope); - getDraftStore().setText(scope, "/compact -t 500"); + typeIntoEdit(editTextarea, "/compact -t 500"); await waitFor(() => expect(editTextarea.value).toBe("/compact -t 500")); fireEvent.keyDown(editTextarea, { key: "Enter" }); @@ -200,7 +225,7 @@ describe("Completing an edit of an older message", () => { return realSend(...args); }); - getDraftStore().setText(scope, "/compact -t 500"); + typeIntoEdit(editTextarea, "/compact -t 500"); await waitFor(() => expect(editTextarea.value).toBe("/compact -t 500")); fireEvent.keyDown(editTextarea, { key: "Enter" }); await waitFor(() => expect(sendSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); @@ -260,9 +285,9 @@ const review = (note: string): ReviewNoteData => ({ }); /** Send an edit of the open edit textarea with `text` and wait until its row replaced the old one. */ -async function sendEdit(app: AppHarness, scope: DraftScope, text: string, replaced: string) { +async function sendEdit(app: AppHarness, text: string, replaced: string) { const textarea = editTextarea(app)!; - getDraftStore().setText(scope, text); + typeIntoEdit(textarea, text); await waitFor(() => expect(textarea.value).toBe(text)); fireEvent.keyDown(textarea, { key: "Enter" }); await app.chat.expectTranscriptContains(text, LOAD_TOLERANT_WAIT.timeout); @@ -399,14 +424,21 @@ describe("Edit sends racing newer composer input (#5226)", () => { await preloadTestModules(); }); - test("no new edit starts while an edit send is pending; the unsent draft comes back in order", async () => { + // ArrowUp edits only from an empty composer, so this edit starts without an unsent draft (an + // edit send shows the unsent draft, see "the composer shows the unsent draft ..." below). + test("no new edit starts while an edit send is pending, by the row's Edit or by ArrowUp", async () => { const app = await createAppHarness({ branchPrefix: "edit-refused-while-pending" }); try { - const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - await startEditWithUnsentDraft(app, scope); + await app.chat.send("first message"); + await app.chat.expectTranscriptContains( + "Mock response: first message", + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + await editRow(app, "first message"); // Hold the edit's reply, and its stream at start: the composer is usable meanwhile. const replies = holdSendReplies(app); - await sendEdit(app, scope, "[mock:wait-start] edited message", "first message"); + await sendEdit(app, "[mock:wait-start] edited message", "first message"); // A second edit (the row's Edit action) and a third (ArrowUp in the empty composer). await expectEditRefused(app, "edited message"); @@ -415,20 +447,10 @@ describe("Edit sends racing newer composer input (#5226)", () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); expect(editTextarea(app)).toBeNull(); - await app.chat.typeWithoutSending("typed meanwhile"); replies.release(); app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); await app.chat.expectStreamComplete(); - await app.chat.expectInputValue( - "unsent draft\n\ntyped meanwhile", - LOAD_TOLERANT_WAIT.timeout - ); - expect( - getDraftStore() - .getView(scope) - .attachments.map(({ id }) => id) - ).toEqual(["file-unsent"]); // Edits work again once the send settled. await waitFor(() => expect(rowEditButton(app, "edited message")?.disabled).toBe(false)); replies.spy.mockRestore(); @@ -458,7 +480,7 @@ describe("Edit sends racing newer composer input (#5226)", () => { await sendGate; return realSend(...args); }); - getDraftStore().setText(scope, "/compact -t 500"); + typeIntoEdit(editTextarea0, "/compact -t 500"); await waitFor(() => expect(editTextarea0.value).toBe("/compact -t 500")); fireEvent.keyDown(editTextarea0, { key: "Enter" }); await waitFor(() => expect(sendSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); @@ -480,7 +502,7 @@ describe("Edit sends racing newer composer input (#5226)", () => { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; const textarea = await startEditWithUnsentDraft(app, scope); const save = await holdNextSendBeforeClear(app); - getDraftStore().setText(scope, "edited message"); + typeIntoEdit(textarea, "edited message"); await waitFor(() => expect(textarea.value).toBe("edited message")); fireEvent.keyDown(textarea, { key: "Enter" }); @@ -512,7 +534,7 @@ describe("Edit sends racing newer composer input (#5226)", () => { await editRow(app, "first message"); const replies = holdSendReplies(app); - await sendEdit(app, scope, "edited message", "first message"); + await sendEdit(app, "edited message", "first message"); await app.chat.expectStreamComplete(); // A note attached while the edit's reply is pending. await attachStoreReview(app, "review-late", "late note"); @@ -538,16 +560,18 @@ describe("Edit sends racing newer composer input (#5226)", () => { await startEditWithUnsentDraft(app, scope); // Hold the edit's reply, and its stream at start: the composer is usable meanwhile. const replies = holdSendReplies(app); - await sendEdit(app, scope, "[mock:wait-start] edited message", "first message"); + await sendEdit(app, "[mock:wait-start] edited message", "first message"); + // While the edit send is in flight the composer shows the unsent draft. + await app.chat.expectInputValue("unsent draft", LOAD_TOLERANT_WAIT.timeout); const save = await holdNextSendBeforeClear(app); await app.chat.typeWithoutSending("follow-up"); pressEnterInComposer(app, "follow-up"); - // The edit completes while the follow-up waits: the pre-edit draft comes back. + // The edit completes while the follow-up waits: nothing comes back into the composer. replies.release(); await waitFor( - () => expect(getDraftStore().getText(scope)).toBe("unsent draft\n\nfollow-up"), + () => expect(getDraftStore().getText(scope)).toBe("follow-up"), LOAD_TOLERANT_WAIT ); @@ -561,7 +585,6 @@ describe("Edit sends racing newer composer input (#5226)", () => { "Mock response: follow-up", LOAD_TOLERANT_WAIT.timeout ); - await expectUnsentDraftKept(app, scope); replies.spy.mockRestore(); save.spy.mockRestore(); } finally { @@ -578,11 +601,11 @@ describe("Edit sends racing newer composer input (#5226)", () => { await app.chat.expectTranscriptContains("Mock response", LOAD_TOLERANT_WAIT.timeout); await app.chat.expectStreamComplete(); // The edit's pre-edit draft has its own note list. - const restoredText = await restoreQueuedMessageWithNote(app, scope, "pre-edit note"); + await restoreQueuedMessageWithNote(app, scope, "pre-edit note"); await editRow(app, "first message"); const replies = holdSendReplies(app); - await sendEdit(app, scope, "[mock:wait-start] edited message", "first message"); + await sendEdit(app, "[mock:wait-start] edited message", "first message"); // While the edit's stream starts, a queued message without notes goes back into the // composer: its note list is empty, and that is what the follow-up send captures. @@ -593,10 +616,10 @@ describe("Edit sends racing newer composer input (#5226)", () => { const save = await holdNextSendBeforeClear(app); pressEnterInComposer(app, "second follow-up"); - // The edit completes while the follow-up waits: its draft and note come back. + // The edit completes while the follow-up waits: its note comes back, and no text. replies.release(); await waitFor( - () => expect(getDraftStore().getText(scope)).toBe(`${restoredText}\n\nsecond follow-up`), + () => expect(getDraftStore().getText(scope)).toBe("second follow-up"), LOAD_TOLERANT_WAIT ); @@ -610,7 +633,7 @@ describe("Edit sends racing newer composer input (#5226)", () => { "Mock response: second follow-up", LOAD_TOLERANT_WAIT.timeout ); - await app.chat.expectInputValue(restoredText, LOAD_TOLERANT_WAIT.timeout); + await app.chat.expectInputValue("", LOAD_TOLERANT_WAIT.timeout); await waitFor( () => expect(reviewPanelNotes(app).join("\n")).toContain("pre-edit note"), LOAD_TOLERANT_WAIT @@ -631,7 +654,6 @@ describe("Edit refused because history changed (B8)", () => { test("the failure alert goes away once the reviewed edit is sent", async () => { const app = await createAppHarness({ branchPrefix: "edit-history-changed-alert" }); try { - const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; await app.chat.send("first message"); await app.chat.expectTranscriptContains( "Mock response: first message", @@ -645,7 +667,7 @@ describe("Edit refused because history changed (B8)", () => { const sendSpy = jest .spyOn(workspaceService, "sendMessage") .mockResolvedValueOnce(Err({ type: "history-changed" })); - getDraftStore().setText(scope, "edited message"); + typeIntoEdit(editTextarea(app)!, "edited message"); await waitFor(() => expect(editTextarea(app)?.value).toBe("edited message")); fireEvent.keyDown(editTextarea(app)!, { key: "Enter" }); await waitFor( @@ -673,3 +695,1146 @@ describe("Edit refused because history changed (B8)", () => { } }, 120_000); }); + +// --------------------------------------------------------------------------------------------- +// The open edit stays out of the shared draft (#5672, #5571). The unsent draft (U) never leaves +// the draft store; the edit's text and files (E) live in the composer that opened the edit. +// --------------------------------------------------------------------------------------------- + +const UNSENT_FILE = { + kind: "provider" as const, + id: "file-unsent", + url: "data:text/plain;base64,dW5zZW50", + mediaType: "text/plain", + filename: "unsent.txt", +}; + +/** The composer's normal (not edit) textarea. */ +function messageTextarea(app: AppHarness): HTMLTextAreaElement { + const textarea = app.view.container.querySelector( + 'textarea[aria-label="Message"]' + ); + if (!textarea) throw new Error("Message textarea not found"); + return textarea; +} + +/** Another renderer on the same backend: a reload of this window, or a second window. */ +async function otherRenderer(app: AppHarness): Promise { + const store = new DraftStore(); + store.setClient(createTestApiClient(app.env.orpc)); + await store.whenReady(); + return store; +} + +/** Show a workspace the way the sidebar does, and wait until its composer is mounted. */ +async function showWorkspace(app: AppHarness, workspaceId: string, name: string) { + const row = await waitFor(() => { + const element = app.view.container.querySelector(`[data-workspace-id="${workspaceId}"]`); + if (!element || element.getAttribute("aria-disabled") === "true") { + throw new Error("Workspace row not selectable yet"); + } + return element as HTMLElement; + }, LOAD_TOLERANT_WAIT); + fireEvent.click(row); + workspaceStore.setActiveWorkspaceId(workspaceId); + await waitFor(() => { + expect(document.title.startsWith(name)).toBe(true); + expect(app.view.container.querySelector('[data-testid="message-window"]')).not.toBe(null); + }, LOAD_TOLERANT_WAIT); +} + +/** A second workspace of the same project, known to the sidebar. */ +async function addOtherWorkspace(app: AppHarness, prefix: string) { + const created = await app.env.orpc.workspace.create({ + projectPath: app.repoPath, + branchName: generateBranchName(prefix), + trunkBranch: await detectDefaultTrunkBranch(app.repoPath), + }); + if (!created.success) throw new Error(created.error); + workspaceStore.addWorkspace(created.metadata); + return created.metadata; +} + +/** Show `other`, then the harness workspace again: the composer unmounts and mounts again. */ +async function switchAwayAndBack(app: AppHarness, other: { id: string; name: string }) { + await showWorkspace(app, other.id, other.name); + await showWorkspace(app, app.workspaceId, app.metadata.name); +} + +/** + * Visit `other` once and send a message there, then show the harness workspace again: with + * cached rows in both, ChatPane stays mounted across later switches. + */ +async function visitWithMessage(app: AppHarness, other: { id: string; name: string }) { + await showWorkspace(app, other.id, other.name); + const otherChat = new ChatHarness(app.view.container, other.id); + await otherChat.send("other message"); + await otherChat.expectTranscriptContains( + "Mock response: other message", + LOAD_TOLERANT_WAIT.timeout + ); + await otherChat.expectStreamComplete(); + await showWorkspace(app, app.workspaceId, app.metadata.name); +} + +/** Stage a file in the composer, so the next sent message (and an edit of it) carries it. */ +async function attachComposerFile(app: AppHarness, filename: string) { + const input = await waitFor(() => { + const element = app.view.container.querySelector( + '[data-component="ChatInputSection"] input[type="file"]' + ); + if (!element) throw new Error("File input not found"); + return element; + }, LOAD_TOLERANT_WAIT); + fireEvent.change(input, { + target: { files: [new File(["# file"], filename, { type: "text/markdown" })] }, + }); + await waitFor(() => expect(composerText(app)).toContain(filename), LOAD_TOLERANT_WAIT); +} + +const attachmentNames = (attachments: { filename?: string; id: string }[]) => + attachments.map((attachment) => attachment.filename ?? attachment.id); +const draftFileNames = (scope: DraftScope) => + attachmentNames(getDraftStore().getView(scope).attachments); + +/** How many times `needle` occurs in `text`. */ +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +/** + * The draft holds `text` and the files `names`, in this renderer, on screen (when no edit is + * open) and on the backend. + */ +async function expectDraft(app: AppHarness, scope: DraftScope, text: string, names: string[]) { + await waitFor(() => expect(getDraftStore().getText(scope)).toBe(text), LOAD_TOLERANT_WAIT); + expect(draftFileNames(scope)).toEqual(names); + if (!editTextarea(app)) expect(messageTextarea(app).value).toBe(text); + await getDraftStore().flush(scope); + const saved = await app.env.services.draftService.get(scope); + expect(saved.text).toBe(text); + expect(attachmentNames(saved.attachments)).toEqual(names); +} + +type SendMessage = AppHarness["env"]["services"]["workspaceService"]["sendMessage"]; +type SendSpy = jest.SpyInstance, Parameters>; + +/** + * Hold edit sends (requests with an `editMessageId`) until released; other sends go through. + * "accept": the held edit then reaches the backend. "reply-only": the backend takes the edit at + * once (its row is replaced) and only the reply waits. "history-changed" and "refuse": the held + * edit is then refused with that error. + */ +function holdEditSends( + app: AppHarness, + mode: "accept" | "reply-only" | "history-changed" | "refuse" = "accept" +) { + const workspaceService = app.env.services.workspaceService; + const realSend: SendMessage = workspaceService.sendMessage.bind(workspaceService); + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const spy: SendSpy = jest + .spyOn(workspaceService, "sendMessage") + .mockImplementation(async (...args: Parameters) => { + if (args[2].editMessageId === undefined) return realSend(...args); + const reply = mode === "reply-only" ? await realSend(...args) : null; + await gate; + if (reply) return reply; + if (mode === "accept") return realSend(...args); + return mode === "refuse" + ? Err({ type: "unknown", raw: "edit refused" }) + : Err({ type: "history-changed" }); + }); + return { release, spy }; +} + +const editRequests = (spy: SendSpy) => + spy.mock.calls.filter(([, , options]) => options.editMessageId !== undefined).length; + +/** Let pending promise continuations (a late send reply, a refresh outcome) run. */ +async function settleAsyncWork() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); +} + +/** How many notes in the composer's review panel show `note`. */ +const notesShowing = (app: AppHarness, note: string) => + reviewPanelNotes(app).filter((text) => text.includes(note)).length; + +/** Send `text` with one attached note, and wait until its reply is complete. */ +async function sendWithNote(app: AppHarness, text: string, id: string, note: string) { + await attachStoreReview(app, id, note); + await waitFor(() => expect(composerText(app)).toContain(note), LOAD_TOLERANT_WAIT); + await app.chat.send(text); + await app.chat.expectTranscriptContains(text, LOAD_TOLERANT_WAIT.timeout); + await app.chat.expectTranscriptContains("Mock response", LOAD_TOLERANT_WAIT.timeout); + await app.chat.expectStreamComplete(); +} + +/** Type the unsent draft and its file into the composer. */ +async function typeUnsentDraft(app: AppHarness, scope: DraftScope) { + await app.chat.typeWithoutSending("unsent draft"); + getDraftStore().setAttachments(scope, [UNSENT_FILE]); +} + +/** Keep the workspace busy (its stream waits at start) and queue `text` behind it. */ +async function queueBehindHeldStream(app: AppHarness, text: string, note?: string) { + const session = app.env.services.workspaceService.getOrCreateSession(app.workspaceId); + const options = { model: "openai:gpt-5.2", agentId: "exec" } as const; + if (!session.isBusy()) { + void app.env.orpc.workspace + .sendMessage({ + workspaceId: app.workspaceId, + message: "[mock:wait-start] hold the workspace busy", + options, + }) + .catch(() => undefined); + await waitFor(() => expect(session.isBusy()).toBe(true), LOAD_TOLERANT_WAIT); + } + await app.env.orpc.workspace.sendMessage({ + workspaceId: app.workspaceId, + message: text, + options: note + ? { ...options, muxMetadata: { type: "normal", reviews: [review(note)] } } + : options, + }); + await waitFor(() => expect(session.hasQueuedMessages()).toBe(true), LOAD_TOLERANT_WAIT); + return session; +} + +/** Right-click transcript text that shows `text` and pick "Quote in input". */ +async function quoteTranscriptText(app: AppHarness, text: string) { + const target = await waitFor(() => { + const element = [...app.view.container.querySelectorAll("[data-message-block] *")] + .reverse() + .find( + (candidate) => candidate.children.length === 0 && candidate.textContent?.includes(text) + ); + if (!element) throw new Error(`Transcript text "${text}" not found`); + return element; + }, LOAD_TOLERANT_WAIT); + fireEvent.contextMenu(target); + const quote = await waitFor(() => { + const item = [...document.body.querySelectorAll("button")].find((button) => + button.textContent?.includes("Quote in input") + ); + if (!item) throw new Error("Quote in input not found"); + return item; + }, LOAD_TOLERANT_WAIT); + fireEvent.click(quote); +} + +function dispatchAppend(text: string, extra?: { workspaceId?: string; fileParts?: FilePart[] }) { + act(() => { + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, { text, mode: "append", ...extra }) + ); + }); +} + +describe("An open edit never reaches the shared draft (#5672, #5571)", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + // T1 + test("a reload during an edit keeps the unsent draft and its files (#5672)", async () => { + const app = await createAppHarness({ branchPrefix: "edit-reload-keeps-draft" }); + let reloaded: DraftStore | null = null; + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + // Typing in the edit writes only the composer's memory, never the persisted draft store. + const setText = jest.spyOn(getDraftStore(), "setText"); + const setAttachments = jest.spyOn(getDraftStore(), "setAttachments"); + for (const typed of ["e", "ed", "edi", "edit", "edited before reload"]) { + typeIntoEdit(textarea, typed); + await waitFor(() => expect(textarea.value).toBe(typed)); + } + const workspaceCalls = (spy: typeof setText | typeof setAttachments) => + spy.mock.calls.filter(([callScope]) => callScope.kind === "workspace").length; + expect(workspaceCalls(setText)).toBe(0); + expect(workspaceCalls(setAttachments)).toBe(0); + setText.mockRestore(); + setAttachments.mockRestore(); + + await getDraftStore().flush(scope); + reloaded = await otherRenderer(app); + expect(reloaded.getText(scope)).toBe("unsent draft"); + const saved = await app.env.services.draftService.get(scope); + expect(saved.text).toBe("unsent draft"); + expect(saved.attachments.map(({ id }) => id)).toEqual(["file-unsent"]); + } finally { + reloaded?.setClient(null); + await app.dispose(); + } + }, 120_000); + + // T2 + test("a second window keeps the unsent draft while the first edits (#5571)", async () => { + const app = await createAppHarness({ branchPrefix: "edit-other-window" }); + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const secondWindow = await otherRenderer(app); + try { + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited in window one"); + await waitFor(() => expect(textarea.value).toBe("edited in window one")); + await getDraftStore().flush(scope); + expect((await app.env.services.draftService.get(scope)).text).toBe("unsent draft"); + await waitFor(() => expect(secondWindow.getText(scope)).toBe("unsent draft")); + + // The second window changes the draft; the editing window shows it once its edit ends. + secondWindow.setText(scope, "unsent draft, changed in window two"); + await secondWindow.flush(scope); + await waitFor( + () => expect(getDraftStore().getText(scope)).toBe("unsent draft, changed in window two"), + LOAD_TOLERANT_WAIT + ); + expect(textarea.value).toBe("edited in window one"); + fireEvent.keyDown(textarea, { key: "Escape" }); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectDraft(app, scope, "unsent draft, changed in window two", ["unsent.txt"]); + } finally { + secondWindow.setClient(null); + await app.dispose(); + } + }, 120_000); + + // T11: dev builds render under StrictMode, whose extra mount cleanup must move nothing. + test("under React StrictMode, a refused edit send reopens the edit", async () => { + const app = await createAppHarness({ branchPrefix: "edit-strict-refused", strictMode: true }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, "refuse"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + sends.release(); + await waitFor(() => expect(composerText(app)).toContain("edit refused"), LOAD_TOLERANT_WAIT); + await waitFor( + () => expect(editTextarea(app)?.value).toBe("edited message"), + LOAD_TOLERANT_WAIT + ); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); +}); + +describe("Inserts while an edit is open (D2-B)", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + // T22 (a) + test("Quote in input appends to the open edit, and the unsent draft does not change", async () => { + const app = await createAppHarness({ branchPrefix: "edit-quote-into-edit" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + await quoteTranscriptText(app, "Mock response: first message"); + await waitFor(() => { + expect(textarea.value.startsWith("edited message\n\n> ")).toBe(true); + expect(textarea.value).toContain("Mock response: first message"); + }, LOAD_TOLERANT_WAIT); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + } finally { + await app.dispose(); + } + }, 120_000); +}); + +describe("An edit that ends without a send keeps its text after the unsent draft", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + // T4 + test("a workspace switch during an edit puts the edit text and files after the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-switch-moves-edit" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-switch-moves-edit-other"); + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + "edit-file.md", + ]); + // Once: a second round trip moves nothing more. + await switchAwayAndBack(app, other); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + "edit-file.md", + ]); + } finally { + await app.dispose(); + } + }, 120_000); + + // T7 (a): the transcript drops the edited row (CP:1378); the edit's notes stay, as on main. + test("an edit whose row is deleted keeps its text after the unsent draft, and its notes", async () => { + const app = await createAppHarness({ branchPrefix: "edit-row-deleted-keeps" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await sendWithNote(app, "first message", "review-row", "row note"); + await typeUnsentDraft(app, scope); + await editRow(app, "first message"); + await waitFor(() => expect(notesShowing(app, "row note")).toBe(1), LOAD_TOLERANT_WAIT); + typeIntoEdit(editTextarea(app)!, "edited message"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("edited message")); + + const cleared = await app.env.services.workspaceService.truncateHistory(app.workspaceId); + expect(cleared.success).toBe(true); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + ]); + expect(notesShowing(app, "row note")).toBe(1); + } finally { + await app.dispose(); + } + }, 120_000); + + // T7 (b) + test("an edit whose refused send's refresh finds no target keeps its text after the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-target-gone-keeps" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const sendSpy = jest + .spyOn(app.env.services.workspaceService, "sendMessage") + .mockResolvedValueOnce(Err({ type: "history-changed" })); + const refreshSpy = jest + .spyOn(WorkspaceStore.prototype, "requestTranscriptRefresh") + .mockResolvedValue({ kind: "target-not-found" }); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(refreshSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + ]); + refreshSpy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T8 + test("a second Edit keeps the first edit's text and files after the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-second-edit-keeps" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await app.chat.send("earlier message"); + await app.chat.expectTranscriptContains( + "Mock response: earlier message", + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + + await editRow(app, "earlier message"); + expect(composerText(app)).not.toContain("edit-file.md"); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + "edit-file.md", + ]); + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + expect(messageTextarea(app).value).toBe(joinDraftText("unsent draft", "edited message")); + } finally { + await app.dispose(); + } + }, 120_000); + + // T9: the composer is replaced by the read-only notice. + test("an edit keeps its text and files after the unsent draft when the workspace turns transcript-only", async () => { + const app = await createAppHarness({ branchPrefix: "edit-transcript-only-keeps" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + app.env.services.workspaceService.emit("metadata", { + workspaceId: app.workspaceId, + metadata: { ...app.metadata, transcriptOnly: true }, + }); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await waitFor( + () => + expect(getDraftStore().getText(scope)).toBe( + joinDraftText("unsent draft", "edited message") + ), + LOAD_TOLERANT_WAIT + ); + expect(draftFileNames(scope)).toEqual(["unsent.txt", "edit-file.md"]); + await getDraftStore().flush(scope); + const saved = await app.env.services.draftService.get(scope); + expect(saved.text).toBe(joinDraftText("unsent draft", "edited message")); + expect(attachmentNames(saved.attachments)).toEqual(["unsent.txt", "edit-file.md"]); + } finally { + await app.dispose(); + } + }, 120_000); + + // T13: the command took its text and files when it started, so the switch moves nothing. + test("a /compact edit accepted after a switch leaves the unsent draft only, without the edit's files", async () => { + const app = await createAppHarness({ branchPrefix: "edit-compact-switch-accepted" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-compact-switch-accepted-other"); + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app); + typeIntoEdit(textarea, "/compact -t 500"); + await waitFor(() => expect(textarea.value).toBe("/compact -t 500")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + sends.release(); + await app.chat.expectStreamComplete(60_000); + await settleAsyncWork(); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T16: the switch ends the edit while its send waits for a settings save, before the take. + test("a switch while the edit send is still preparing sends nothing and puts the edit after the unsent draft once", async () => { + const app = await createAppHarness({ branchPrefix: "edit-switch-while-preparing" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-switch-while-preparing-other"); + const textarea = await startEditWithUnsentDraft(app, scope); + const sendSpy = jest.spyOn(app.env.services.workspaceService, "sendMessage"); + const save = await holdNextSendBeforeClear(app); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await switchAwayAndBack(app, other); + save.release(); + await settleAsyncWork(); + expect(editRequests(sendSpy)).toBe(0); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + ]); + await app.chat.expectTranscriptContains("first message", LOAD_TOLERANT_WAIT.timeout); + save.spy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); +}); + +// A switch unmounts the composer (ChatPane keys it by workspace) while its edit send or command +// is still in flight. That late completion must not touch the composer shown now. +describe("An edit send that settles after its composer unmounted", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + // T6 + test("a history-changed refusal after a switch puts the edit after the unsent draft once and starts no refresh", async () => { + const app = await createAppHarness({ branchPrefix: "edit-refused-after-switch" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-refused-after-switch-other"); + await visitWithMessage(app, other); + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, "history-changed"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + const refreshSpy = jest.spyOn(WorkspaceStore.prototype, "requestTranscriptRefresh"); + + await showWorkspace(app, other.id, other.name); + await editRow(app, "other message"); + sends.release(); + await settleAsyncWork(); + expect(editTextarea(app)?.value).toBe("other message"); + expect(refreshSpy.mock.calls.filter(([id]) => id === app.workspaceId)).toHaveLength(0); + + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await showWorkspace(app, app.workspaceId, app.metadata.name); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + "edit-file.md", + ]); + refreshSpy.mockRestore(); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T10: ChatPane stays mounted; one edit slot serves both workspaces. Edit-row note: "row note". + for (const targetShown of [true, false]) { + test(`an edit send accepted after a switch (${targetShown ? "target still shown" : "row already replaced"}) leaves an edit in the other workspace open`, async () => { + const app = await createAppHarness({ + branchPrefix: targetShown ? "edit-accepted-after-switch" : "edit-replaced-before-switch", + }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-accepted-after-switch-other"); + await visitWithMessage(app, other); + await sendWithNote(app, "first message", "review-row", "row note"); + await typeUnsentDraft(app, scope); + await editRow(app, "first message"); + const sends = holdEditSends(app, targetShown ? "accept" : "reply-only"); + typeIntoEdit(editTextarea(app)!, "edited message"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("edited message")); + fireEvent.keyDown(editTextarea(app)!, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + if (!targetShown) { + await app.chat.expectTranscriptNotContains("first message", LOAD_TOLERANT_WAIT.timeout); + } + + // Back in this workspace while the send is pending: Edit stays refused (#5226). + await switchAwayAndBack(app, other); + await expectEditRefused(app, targetShown ? "first message" : "edited message"); + + await showWorkspace(app, other.id, other.name); + await editRow(app, "other message"); + typeIntoEdit(editTextarea(app)!, "edited other message"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("edited other message")); + sends.release(); + await Promise.allSettled(sends.spy.mock.results.map((result): unknown => result.value)); + await settleAsyncWork(); + expect(editTextarea(app)?.value).toBe("edited other message"); + + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await showWorkspace(app, app.workspaceId, app.metadata.name); + await app.chat.expectStreamComplete(); + const editedRows = useWorkspaceStoreRaw() + .getWorkspaceState(app.workspaceId) + .messages.filter( + (message) => message.type === "user" && message.content.endsWith("edited message") + ); + expect(editedRows).toHaveLength(1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + expect(notesShowing(app, "row note")).toBe(0); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + } + + // T14 + test("a /compact edit that fails after a switch puts its text and files after the unsent draft once", async () => { + const app = await createAppHarness({ branchPrefix: "edit-compact-switch-failed" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-compact-switch-failed-other"); + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, "refuse"); + typeIntoEdit(textarea, "/compact -t 500"); + await waitFor(() => expect(textarea.value).toBe("/compact -t 500")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + + await switchAwayAndBack(app, other); + sends.release(); + await settleAsyncWork(); + await expectDraft(app, scope, joinDraftText("unsent draft", "/compact -t 500"), [ + "unsent.txt", + "edit-file.md", + ]); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T19 (a) + test("a refresh that finds no target after a switch leaves an edit in the other workspace open", async () => { + const app = await createAppHarness({ branchPrefix: "edit-refresh-after-switch" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-refresh-after-switch-other"); + await visitWithMessage(app, other); + const textarea = await startEditWithUnsentDraft(app, scope); + const sendSpy = jest + .spyOn(app.env.services.workspaceService, "sendMessage") + .mockResolvedValueOnce(Err({ type: "history-changed" })); + let finishRefresh: (outcome: TranscriptRefreshOutcome) => void = () => undefined; + const refreshOutcome = new Promise((resolve) => { + finishRefresh = resolve; + }); + const refreshSpy = jest + .spyOn(WorkspaceStore.prototype, "requestTranscriptRefresh") + .mockImplementation(() => refreshOutcome); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(refreshSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); + + await showWorkspace(app, other.id, other.name); + await editRow(app, "other message"); + finishRefresh({ kind: "target-not-found" }); + await settleAsyncWork(); + expect(editTextarea(app)?.value).toBe("other message"); + + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await showWorkspace(app, app.workspaceId, app.metadata.name); + await expectDraft(app, scope, joinDraftText("unsent draft", "edited message"), [ + "unsent.txt", + ]); + refreshSpy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); +}); + +describe("Edit sends, restores and inserts while the unsent draft waits", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + // T3: the final check's repro on #5801 (R2 c7e7fdbe76 showed the unsent draft twice). + for (const olderHistory of [false, true]) { + test(`a queued message restored during an edit send shows once next to the unsent draft (${olderHistory ? "older history" : "short transcript"})`, async () => { + const app = await createAppHarness({ + branchPrefix: olderHistory ? "edit-restore-queue-long" : "edit-restore-queue-short", + }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + for (const text of olderHistory + ? ["first message", "second message", "third message"] + : ["first message"]) { + await app.chat.send(text); + await app.chat.expectTranscriptContains( + `Mock response: ${text}`, + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + } + await typeUnsentDraft(app, scope); + await queueBehindHeldStream(app, "queued Q"); + await editRow(app, "first message"); + typeIntoEdit(editTextarea(app)!, "edited message"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("edited message")); + fireEvent.keyDown(editTextarea(app)!, { key: "Enter" }); + + // The edit's truncation gives the queued message back to the composer. + await waitFor( + () => expect(getDraftStore().getText(scope)).toContain("queued Q"), + LOAD_TOLERANT_WAIT + ); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectTranscriptContains( + "Mock response: edited message", + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + await settleAsyncWork(); + const text = getDraftStore().getText(scope); + expect(occurrences(text, "unsent draft")).toBe(1); + expect(occurrences(text, "queued Q")).toBe(1); + expect(draftFileNames(scope).filter((name) => name === "unsent.txt")).toHaveLength(1); + } finally { + await app.dispose(); + } + }, 120_000); + } + + // T5 + test("a refused edit send reopens the edit with its text and file, and the draft stays the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-refused-reopens" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, "refuse"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + sends.release(); + await waitFor( + () => expect(editTextarea(app)?.value).toBe("edited message"), + LOAD_TOLERANT_WAIT + ); + expect(occurrences(composerText(app), "edit-file.md")).toBe(1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T12 (D3-A) + test("while an edit send is pending the composer shows the unsent draft, and typing changes it", async () => { + const app = await createAppHarness({ branchPrefix: "edit-pending-shows-draft" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, "reply-only"); + await sendEdit(app, "[mock:wait-start] edited message", "first message"); + await app.chat.expectInputValue("unsent draft", LOAD_TOLERANT_WAIT.timeout); + fireEvent.change(messageTextarea(app), { + target: { value: "unsent draft, typed meanwhile" }, + }); + await waitFor(() => + expect(getDraftStore().getText(scope)).toBe("unsent draft, typed meanwhile") + ); + + sends.release(); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectStreamComplete(); + await settleAsyncWork(); + await expectDraft(app, scope, "unsent draft, typed meanwhile", ["unsent.txt"]); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T15: the send waits for a settings save before it takes the text; the user keeps typing. + for (const accepted of [true, false]) { + test(`text typed into the edit while its send prepares survives ${accepted ? "an accepted send, after the unsent draft" : "a refused send, after the edit text"}`, async () => { + const app = await createAppHarness({ + branchPrefix: accepted ? "edit-typed-while-sending-ok" : "edit-typed-while-sending-refused", + }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const sendSpy = accepted + ? null + : jest + .spyOn(app.env.services.workspaceService, "sendMessage") + .mockResolvedValueOnce(Err({ type: "unknown", raw: "edit refused" })); + const save = await holdNextSendBeforeClear(app); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + typeIntoEdit(textarea, "edited message\n\ntyped while sending"); + await waitFor(() => expect(textarea.value).toBe("edited message\n\ntyped while sending")); + save.release(); + + if (accepted) { + await app.chat.expectTranscriptContains( + "Mock response: edited message", + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectDraft(app, scope, joinDraftText("unsent draft", "typed while sending"), [ + "unsent.txt", + ]); + } else { + await waitFor( + () => expect(composerText(app)).toContain("edit refused"), + LOAD_TOLERANT_WAIT + ); + await waitFor( + () => + expect(editTextarea(app)?.value).toBe( + joinDraftText("edited message", "typed while sending") + ), + LOAD_TOLERANT_WAIT + ); + } + save.spy.mockRestore(); + sendSpy?.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + } + + // T17 (a) + test("the queued card's Edit while an edit is visible leaves the message queued and changes nothing", async () => { + const app = await createAppHarness({ branchPrefix: "edit-queued-card-refused" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + const session = await queueBehindHeldStream(app, "queued Q"); + await editQueuedMessage(app); + await settleAsyncWork(); + expect(session.hasQueuedMessages()).toBe(true); + expect(editTextarea(app)?.value).toBe("edited message"); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectStreamComplete(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T17 (b) + test("the queued card's Edit during an edit send puts the message in front of the shown unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-queued-card-during-send" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, "reply-only"); + await sendEdit(app, "[mock:wait-start] edited message", "first message"); + await queueBehindHeldStream(app, "queued Q", "queued note"); + await editQueuedMessage(app); + await waitFor( + () => + expect(getDraftStore().getText(scope)).toBe(joinDraftText("queued Q", "unsent draft")), + LOAD_TOLERANT_WAIT + ); + expect(draftFileNames(scope)).toEqual(["unsent.txt"]); + // The note joins the attached notes in the review store, not the composer's override. + await waitFor(() => + expect( + getReviewStateStore() + .getAttachedReviews(app.workspaceId) + .filter((attached) => attached.data.userNote === "queued note") + ).toHaveLength(1) + ); + + sends.release(); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectStreamComplete(); + await settleAsyncWork(); + await expectDraft(app, scope, joinDraftText("queued Q", "unsent draft"), ["unsent.txt"]); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T19 (b) + test("an old Retry refresh starts no refresh after Cancel and reopening the same row", async () => { + const app = await createAppHarness({ branchPrefix: "edit-old-retry-ignored" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const sendSpy = jest + .spyOn(app.env.services.workspaceService, "sendMessage") + .mockResolvedValueOnce(Err({ type: "history-changed" })); + const refreshSpy = jest + .spyOn(WorkspaceStore.prototype, "requestTranscriptRefresh") + .mockResolvedValue({ kind: "failed", error: "refresh unavailable" }); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor( + () => expect(composerText(app)).toContain("transcript refresh failed"), + LOAD_TOLERANT_WAIT + ); + + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await editRow(app, "first message"); + typeIntoEdit(editTextarea(app)!, "reopened edit"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("reopened edit")); + const retries = [...document.body.querySelectorAll("button")].filter( + (button) => button.textContent === EDIT_RETRY_REFRESH_LABEL + ); + expect(retries.length).toBeGreaterThan(0); + const refreshCalls = refreshSpy.mock.calls.length; + for (const retry of retries) fireEvent.click(retry); + await settleAsyncWork(); + expect(refreshSpy.mock.calls.length).toBe(refreshCalls); + expect(editTextarea(app)?.value).toBe("reopened edit"); + expect(composerText(app)).not.toContain("history changed"); + refreshSpy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T20: no dispatcher sends files with "append"; the test dispatches it. + test("an append with a file during an open edit changes neither the edit nor the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-append-file-dropped" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + dispatchAppend("appended text", { + fileParts: [ + { + url: "data:text/plain;base64,YXBwZW5kZWQ=", + mediaType: "text/plain", + filename: "appended.txt", + }, + ], + }); + await settleAsyncWork(); + expect(textarea.value).toBe("edited message"); + expect(composerText(app)).not.toContain("appended.txt"); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + } finally { + await app.dispose(); + } + }, 120_000); + + // T21: /compact turns a failed request into a "restore"; a rejection escapes only from the + // command chain itself, so the test makes the command's continuation reject. + test("an editing /compact whose request rejects puts its text and files back into the edit once", async () => { + const app = await createAppHarness({ branchPrefix: "edit-compact-rejects" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope); + const realProcess = chatCommands.processSlashCommand; + const commandSpy = jest + .spyOn(chatCommands, "processSlashCommand") + .mockImplementation(async (...args: Parameters) => { + const result = await realProcess(...args); + return result.kind === "phase" + ? { + ...result, + continue: () => Promise.reject(new Error("compaction request rejected")), + } + : result; + }); + typeIntoEdit(textarea, "/compact -t 500"); + await waitFor(() => expect(textarea.value).toBe("/compact -t 500")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(commandSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); + await settleAsyncWork(); + await waitFor( + () => expect(editTextarea(app)?.value).toBe("/compact -t 500"), + LOAD_TOLERANT_WAIT + ); + expect(occurrences(composerText(app), "edit-file.md")).toBe(1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + commandSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T22 (b) and (c): CommandPalette.tsx:276 sends no workspace id; McpAppFrame.tsx:278 does. + for (const fromMcpApp of [false, true]) { + test(`${fromMcpApp ? "an MCP App message" : "a palette insert"} appends to the open edit, and the unsent draft does not change`, async () => { + const app = await createAppHarness({ + branchPrefix: fromMcpApp ? "edit-mcp-insert" : "edit-palette-insert", + }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + dispatchAppend("inserted text", fromMcpApp ? { workspaceId: app.workspaceId } : undefined); + await waitFor(() => expect(textarea.value).toBe("edited message\n\ninserted text")); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + } finally { + await app.dispose(); + } + }, 120_000); + } + + // T22 (d) + test("a quote during an edit send appends to the shown unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-quote-during-send" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + for (const text of ["first message", "second message"]) { + await app.chat.send(text); + await app.chat.expectTranscriptContains( + `Mock response: ${text}`, + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + } + await typeUnsentDraft(app, scope); + await editRow(app, "second message"); + const sends = holdEditSends(app, "reply-only"); + await sendEdit(app, "[mock:wait-start] edited message", "Mock response: second message"); + await quoteTranscriptText(app, "Mock response: first message"); + await waitFor(() => { + const text = getDraftStore().getText(scope); + expect(text.startsWith("unsent draft\n\n> ")).toBe(true); + expect(text).toContain("Mock response: first message"); + }, LOAD_TOLERANT_WAIT); + expect(messageTextarea(app).value).toBe(getDraftStore().getText(scope)); + sends.release(); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectStreamComplete(); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // T23: the file add checks for an edit before its awaits; the edit opens while it stages. + test("a file add that lands after Edit opens is refused and writes neither the edit nor the draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-late-file-refused" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await app.chat.send("first message"); + await app.chat.expectTranscriptContains( + "Mock response: first message", + LOAD_TOLERANT_WAIT.timeout + ); + await app.chat.expectStreamComplete(); + await typeUnsentDraft(app, scope); + const workspaceService = app.env.services.workspaceService; + const realStage = workspaceService.stageAttachment.bind(workspaceService); + let releaseStage: () => void = () => undefined; + const stageGate = new Promise((resolve) => { + releaseStage = resolve; + }); + const stageSpy = jest + .spyOn(workspaceService, "stageAttachment") + .mockImplementation(async (...args: Parameters) => { + await stageGate; + return realStage(...args); + }); + const input = app.view.container.querySelector( + '[data-component="ChatInputSection"] input[type="file"]' + )!; + fireEvent.change(input, { + target: { files: [new File(["# late"], "late.md", { type: "text/markdown" })] }, + }); + await waitFor(() => expect(stageSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); + await editRow(app, "first message"); + releaseStage(); + await waitFor( + () => + expect(composerText(app)).toContain( + "Attachments cannot be added while editing a message." + ), + LOAD_TOLERANT_WAIT + ); + await settleAsyncWork(); + expect(composerText(app)).not.toContain("late.md"); + expect(editTextarea(app)?.value).toBe("first message"); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + stageSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); +}); diff --git a/tests/ui/harness/createAppHarness.ts b/tests/ui/harness/createAppHarness.ts index 238daf8f662..62d506b5a7e 100644 --- a/tests/ui/harness/createAppHarness.ts +++ b/tests/ui/harness/createAppHarness.ts @@ -54,6 +54,8 @@ export async function createAppHarness(options?: { * workspace-scoped persisted state (e.g. draft attachments). */ beforeRender?: (workspaceId: string) => void; + /** Render the app under React StrictMode (dev builds do). */ + strictMode?: boolean; }): Promise { const repoPath = await createTempGitRepo(); const env = await createTestEnvironment(); @@ -92,7 +94,7 @@ export async function createAppHarness(options?: { cleanupDom = installDom(); options?.beforeRender?.(workspaceId); - view = renderApp({ apiClient: env.orpc, metadata }); + view = renderApp({ apiClient: env.orpc, metadata, strictMode: options?.strictMode }); await setupWorkspaceView(view, metadata, workspaceId); await waitForWorkspaceChatToRender(view.container); diff --git a/tests/ui/renderReviewPanel.tsx b/tests/ui/renderReviewPanel.tsx index 43f6ab60527..0ab6971ebbc 100644 --- a/tests/ui/renderReviewPanel.tsx +++ b/tests/ui/renderReviewPanel.tsx @@ -1,4 +1,5 @@ import { render, type RenderResult, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; import { AppLoader } from "@/browser/components/AppLoader/AppLoader"; import type { APIClient } from "@/browser/contexts/API"; @@ -8,6 +9,8 @@ interface RenderReviewPanelParams { apiClient: APIClient; /** Metadata for the workspace to select (optional - app can render without a workspace) */ metadata?: FrontendWorkspaceMetadata; + /** Render under React StrictMode, as dev builds do (main.tsx). */ + strictMode?: boolean; } export interface RenderedApp extends RenderResult { @@ -34,7 +37,8 @@ export function renderReviewPanel(props: RenderReviewPanelParams): RenderedApp { * This exercises the real component tree, providers, and state management. */ export function renderApp(props: RenderReviewPanelParams): RenderedApp { - const result = render(); + const app = ; + const result = render(props.strictMode ? {app} : app); return { ...result, From 016a1671b75e18bcb97f2631b10183d762e62ea2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 14:13:45 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20an=20open=20edit=20st?= =?UTF-8?q?ays=20out=20of=20the=20shared=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While an edit is open, its text and files live only in the composer (window-local). The shared persisted draft keeps the unsent draft, so a reload, a second window, a workspace switch or a crash during an edit no longer loses it (#5672, #5571). - useComposerDraft holds the edit's text and files in local state and writes nothing to DraftStore while the edit is visible. When the composer unmounts with an open edit, the edit is appended after the unsent draft once. - Inserts while an edit is visible (Quote in input, palette, MCP App messages) append to the edit; file appends and late file adds are refused instead of replacing the draft behind the edit. - Restores during an edit send (queued card Edit, backend restores) merge in front of the shown unsent draft; their notes go to the review store. - A rejected command phase puts an editing command's input back once. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- src/browser/components/ChatPane/ChatPane.tsx | 4 +- src/browser/features/ChatInput/index.tsx | 203 +++++++++++++----- src/browser/features/ChatInput/types.ts | 4 + .../ChatInput/useComposerAttachments.ts | 6 +- .../features/ChatInput/useComposerDraft.ts | 58 +++++ src/browser/hooks/useAIViewKeybinds.test.tsx | 2 + 6 files changed, 227 insertions(+), 50 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index ec0067eb198..e001818981e 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -873,7 +873,7 @@ const ChatPaneContent: React.FC = (props) => { const chatInputAPI = useRef(null); const handleQuoteText = useCallback((quotedText: string) => { - chatInputAPI.current?.appendText(quotedText); + chatInputAPI.current?.appendToVisibleInput(quotedText); chatInputAPI.current?.focus(); }, []); @@ -939,6 +939,8 @@ const ChatPaneContent: React.FC = (props) => { async (queuedMessage: QueuedMessageData) => { const inputApi = chatInputAPI.current; if (!inputApi) return; + // A visible edit would hide the restored message behind it: keep it queued instead. + if (inputApi.isEditVisible()) return; await api?.workspace.clearQueue({ workspaceId }); inputApi.restoreDraft(normalizeQueuedMessage(queuedMessage), { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 47857198df2..458a8135acc 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -312,8 +312,6 @@ function pendingChatAttachments( /** One edit, from entering edit mode until it is cancelled or its send is accepted (#5226). */ interface EditSession { id: string; - /** The unsent draft from before the edit, restored when the edit ends. */ - preEditDraft: { text: string; attachments: ChatAttachment[] }; preEditReviews: ReviewNoteDataForDisplay[] | null; /** Its draft was given back (cancel, or accepted send); it restores nothing again. */ settled: boolean; @@ -377,6 +375,7 @@ const ChatInputInner: React.FC = (props) => { const [editRefreshRetry, setEditRefreshRetry] = useState<{ editMessageId: string; precondition: HistoryEditPrecondition; + session: EditSession | null; } | null>(null); // Assigned in a layout effect, not a passive one: a refresh that settles between a commit // that changed the edit and a passive effect must already see the new edit as current. @@ -509,8 +508,25 @@ const ChatInputInner: React.FC = (props) => { attachedReviews: variant === "workspace" ? (props.attachedReviews ?? []) : [], pushToast, }); - const { input, setInput, attachments, setAttachments, draftReviews, setDraftReviews } = draft; - const { getDraft, setDraft } = draft; + const { setInput, setAttachments, draftReviews, setDraftReviews } = draft; + const { getDraft, setDraft, editDraft, getEditDraft, setEditText, setEditAttachments } = draft; + const { beginEditDraft, dropEditDraft, consumeEditDraftIntoDraft, appendSnapshotToDraft } = draft; + // The composer shows the open edit's buffer, else the normal draft (also during an edit send). + const visibleEdit = + editingMessageForUi && editDraft?.id === editingMessageForUi.id ? editDraft : null; + const input = visibleEdit ? visibleEdit.text : draft.input; + const attachments = visibleEdit ? visibleEdit.attachments : draft.attachments; + // Typing and inserts pick their target from this ref when they run, never from a stale render. + const visibleEditIdRef = useRef(undefined); + const isEditVisible = useCallback(() => visibleEditIdRef.current !== undefined, []); + // Typing, voice and suggestions: into the visible edit, never the shared draft (#5672, #5571). + const setTypedText = useCallback( + (value: string | ((previous: string) => string)) => { + if (isEditVisible()) setEditText(value); + else setInput(value); + }, + [isEditVisible, setEditText, setInput] + ); const { reviewOverrideActive, reviewData, reviewIdsForCheck, reviewPanelItems } = draft; const { removeDraftReview, updateDraftReviewNote, draftScope, latestInputValueRef } = draft; const { @@ -525,6 +541,7 @@ const ChatInputInner: React.FC = (props) => { workspaceId, setAttachments, editingMessage: editingMessageForUi != null, + isEditVisible, pushToast, }); const workspaceIdForComposerClear = variant === "workspace" ? props.workspaceId : null; @@ -539,6 +556,9 @@ const ChatInputInner: React.FC = (props) => { useEffect(() => { return () => { isMountedRef.current = false; + // No edit session survives an unmount (the draft hook moves the buffer to the draft). + editSessionRef.current = null; + appliedEditIdRef.current = null; }; }, []); const inputRef = useRef(null); @@ -589,7 +609,7 @@ const ChatInputInner: React.FC = (props) => { const converted = convertSymbolCommandAtCursor(next, caret) ?? convertTerminatedSymbolCommand(next, caret); if (converted) { - setInput(converted.text); + setTypedText(converted.text); const newCursor = converted.cursor; requestAnimationFrame(() => { const el = inputRef.current; @@ -602,10 +622,10 @@ const ChatInputInner: React.FC = (props) => { return { text: converted.text, cursor: converted.cursor }; } - setInput(next); + setTypedText(next); return { text: next, cursor: caret }; }, - [latestInputValueRef, powerMode, setInput] + [latestInputValueRef, powerMode, setTypedText] ); const { open } = useSettings(); @@ -644,7 +664,7 @@ const ChatInputInner: React.FC = (props) => { // Voice input - appends transcribed text to input const voiceInput = useVoiceInput({ onTranscript: (text) => { - setInput((prev) => { + setTypedText((prev) => { const separator = prev.length > 0 && !prev.endsWith(" ") ? " " : ""; return prev + separator + text; }); @@ -688,7 +708,7 @@ const ChatInputInner: React.FC = (props) => { ); const composerSuggestions = useComposerSuggestions({ input, - setInput, + setInput: setTypedText, inputRef, variant, workspaceId, @@ -1202,7 +1222,15 @@ const ChatInputInner: React.FC = (props) => { const retained = getDraftStore().getPendingSendIds(draftScope); const sendIds = options?.retainedSendIds ?? []; const retainedSendIds = sendIds.filter((id) => retained.has(id)); - if (retainedSendIds.length === 0) { + // During an edit (open or in flight) merge in front of the unsent draft; notes to the store. + const session = editSessionRef.current; + const duringEdit = + !!session && !session.settled && session.id === editingMessageIdRef.current; + if (retainedSendIds.length === 0 && duringEdit) { + setInput((current) => joinDraftText(pending.content, current)); + const restored = pendingChatAttachments(pending, `restored-${Date.now()}`); + if (restored.length > 0) setAttachments((current) => [...restored, ...current]); + } else if (retainedSendIds.length === 0) { applyDraftFromPending(pending, `restored-${Date.now()}`); } else { // The draft still retains these sends: they come back as not accepted (prepended to the @@ -1229,13 +1257,15 @@ const ChatInputInner: React.FC = (props) => { } } } - setDraftReviews(pending.reviews); + if (duringEdit && onAddReviewForRestore) pending.reviews.forEach(onAddReviewForRestore); + else setDraftReviews(pending.reviews); focusMessageInput(); }, [ applyDraftFromPending, draftScope, focusMessageInput, + onAddReviewForRestore, setAttachments, setDraftReviews, setInput, @@ -1246,16 +1276,26 @@ const ChatInputInner: React.FC = (props) => { // transcript when the accepted edit replaces it (possibly before the send returns), and that // is not a cancel. const editSessionRef = useRef(null); + // Still this composer's open, unsettled edit and ChatPane's target (none after an unmount). + const isLiveEdit = useCallback( + (session: EditSession | null) => + session?.settled === false && + session === editSessionRef.current && + session.id === editingMessageIdRef.current, + [] + ); // Live review override for completions that settle after the render they started in. const draftReviewsRef = useRef(draftReviews); useLayoutEffect(() => { draftReviewsRef.current = draftReviews; + visibleEditIdRef.current = visibleEdit?.id; }); const restorePreEditDraft = () => { const session = editSessionRef.current; if (!session || session.settled || session.id !== editingMessageIdRef.current) return; session.settled = true; - setDraft(session.preEditDraft); + // The unsent draft never left the draft store: Cancel only drops the edit's buffer. + dropEditDraft(); setDraftReviews(session.preEditReviews); }; @@ -1271,12 +1311,8 @@ const ChatInputInner: React.FC = (props) => { ): boolean => { if (!session || session.settled) return false; session.settled = true; - const { preEditDraft, preEditReviews } = session; + const { preEditReviews } = session; if (dropEditReviews) setDraftReviews(null); - setInput((current) => joinDraftText(preEditDraft.text, current)); - if (preEditDraft.attachments.length > 0) { - setAttachments((current) => [...preEditDraft.attachments, ...current]); - } if (preEditReviews !== null) { if ((dropEditReviews || draftReviewsRef.current === null) && onAddReviewForRestore) { // The notes in effect live in the review store: add the restored ones there too. @@ -1288,8 +1324,10 @@ const ChatInputInner: React.FC = (props) => { return true; }; // By identity, not row id: a row reopened after a cancel is a new edit. + // A composer that unmounted (no session) calls no cancel: ChatPane may show another edit now. const isOpenEditOrNone = (session: EditSession | null) => - editingMessageIdRef.current === undefined || editSessionRef.current === session; + editSessionRef.current !== null && + (editingMessageIdRef.current === undefined || editSessionRef.current === session); // Method to restore text to input (used by compaction cancel) const restoreText = useCallback( @@ -1312,6 +1350,16 @@ const ChatInputInner: React.FC = (props) => { }, [setInput] ); + // Quotes, palette inserts and MCP App messages land where the user sees them (D2-B). + const removeEditAttachment = (id: string) => + setEditAttachments((current) => current.filter((item) => item.id !== id)); + const appendToVisibleInput = useCallback( + (text: string) => { + if (isEditVisible()) setEditText((prev) => prev + (prev.trim() ? "\n\n" : "") + text); + else appendText(text); + }, + [appendText, isEditVisible, setEditText] + ); // Method to prepend text to input (used by manual compact trigger) const prependText = useCallback( @@ -1333,9 +1381,10 @@ const ChatInputInner: React.FC = (props) => { // keep the handed-out object stable even though the draft helpers are not // identity-stable without manual memoization; re-invoking onReady per render // would churn parent state. - const composerApiRef = useRef({ restoreText, restoreDraft, appendText, prependText }); + const composerApi = { restoreText, restoreDraft, appendText, prependText, appendToVisibleInput }; + const composerApiRef = useRef(composerApi); useEffect(() => { - composerApiRef.current = { restoreText, restoreDraft, appendText, prependText }; + composerApiRef.current = composerApi; }); useEffect(() => { if (onReady) { @@ -1346,6 +1395,8 @@ const ChatInputInner: React.FC = (props) => { restoreDraft: (pending, options) => composerApiRef.current.restoreDraft(pending, options), appendText: (text) => composerApiRef.current.appendText(text), prependText: (text) => composerApiRef.current.prependText(text), + appendToVisibleInput: (text) => composerApiRef.current.appendToVisibleInput(text), + isEditVisible: () => visibleEditIdRef.current !== undefined, }); } }, [onReady, focusMessageInput, send]); @@ -1404,6 +1455,9 @@ const ChatInputInner: React.FC = (props) => { const appliedEditIdRef = useRef(null); const draftPayloadsLoaded = draft.payloadsLoaded; useEffect(() => { + // An edit that ended without a send or Cancel (second Edit, deleted row, target not found) + // goes after the unsent draft once. The real target: an edit send keeps its buffer. + if (getEditDraft()?.id !== editingMessage?.id) consumeEditDraftIntoDraft(); if (!editingMessage) { appliedEditIdRef.current = null; return; @@ -1421,11 +1475,14 @@ const ChatInputInner: React.FC = (props) => { appliedEditIdRef.current = editingMessage.id; editSessionRef.current = { id: editingMessage.id, - preEditDraft: getDraft(), preEditReviews: draftReviews, settled: false, }; - applyDraftFromPending(editingMessage.pending, `edit-${editingMessage.id}`); + beginEditDraft({ + id: editingMessage.id, + text: editingMessage.pending.content, + attachments: pendingChatAttachments(editingMessage.pending, `edit-${editingMessage.id}`), + }); setDraftReviews(editingMessage.pending.reviews); // Auto-resize textarea and focus setTimeout(() => { @@ -1440,9 +1497,10 @@ const ChatInputInner: React.FC = (props) => { editingMessage, draftPayloadsLoaded, draftScope, - getDraft, draftReviews, - applyDraftFromPending, + beginEditDraft, + consumeEditDraftIntoDraft, + getEditDraft, setDraftReviews, ]); @@ -1654,7 +1712,11 @@ const ChatInputInner: React.FC = (props) => { // Ids the review store added; null when the notes went to the memory-only override. let restoredReviewIds: string[] | null = []; if (restoredPending.reviews.length > 0) { - if (draftReviews === null && onAddReviewForRestore) { + // During an edit (open or in flight) the override is the edit's: store them too. + if ( + (draftReviews === null || isLiveEdit(editSessionRef.current)) && + onAddReviewForRestore + ) { // The draft's notes live in the review store: add the restored ones there too, as // new attached notes. A detached copy in the override would hide later store // changes, and a send in flight checks off only the notes it captured, not these. @@ -1707,6 +1769,8 @@ const ChatInputInner: React.FC = (props) => { restoreText(restoredPending.content); } } else if (hasFileParts || hasStagedAttachments || hasReviews) { + // No dispatcher sends files with "append": never replace the draft behind a visible edit. + if (isEditVisible()) return; const currentText = getDraft().text; const separator = currentText.trim() ? "\n\n" : ""; applyDraftFromPending( @@ -1717,14 +1781,16 @@ const ChatInputInner: React.FC = (props) => { restoredIdPrefix ); } else { - appendText(restoredPending.content); + appendToVisibleInput(restoredPending.content); } }; window.addEventListener(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, handler as EventListener); return () => window.removeEventListener(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, handler as EventListener); }, [ - appendText, + appendToVisibleInput, + isEditVisible, + isLiveEdit, restoreText, restoreDraft, applyDraftFromPending, @@ -1748,8 +1814,9 @@ const ChatInputInner: React.FC = (props) => { return; } - setInput(""); - setAttachments([]); + // While an edit is visible this clears the edit the composer shows; the draft stays. + (isEditVisible() ? setEditText : setInput)(""); + (isEditVisible() ? setEditAttachments : setAttachments)([]); setDraftReviews(null); onDetachAllReviewsForComposerClear?.(); if (inputRef.current) { @@ -1761,9 +1828,12 @@ const ChatInputInner: React.FC = (props) => { return () => window.removeEventListener(CUSTOM_EVENTS.CLEAR_CHAT_COMPOSER, handler as EventListener); }, [ + isEditVisible, onDetachAllReviewsForComposerClear, setAttachments, setDraftReviews, + setEditAttachments, + setEditText, setInput, workspaceIdForComposerClear, ]); @@ -2060,16 +2130,25 @@ const ChatInputInner: React.FC = (props) => { editSessionRef.current?.id === commandEnv.editMessageId ? editSessionRef.current : null; - // An editing command whose edit gave its draft back (the user cancelled it) or is no longer - // the open edit: the composer holds another draft, so the command's clears must not touch it. - const editCancelled = () => - commandEditSession?.settled === true || !isOpenEditOrNone(commandEditSession); + // An editing command owns its edit's text and files until it settles (a switch or Cancel + // cannot separate them), so its clears find nothing to clear. + let commandSnapshot = commandEditSession ? getEditDraft() : null; + if (commandSnapshot) beginEditDraft({ ...commandSnapshot, text: "", attachments: [] }); + // Puts it back once: in front of the same edit, else after the unsent draft. + const putBackCommandSnapshot = () => { + const snapshot = commandSnapshot; + commandSnapshot = null; + if (!snapshot) return; + if (!isLiveEdit(commandEditSession)) return appendSnapshotToDraft(snapshot); + setEditText((current) => joinDraftText(snapshot.text, current)); + setEditAttachments((current) => [...snapshot.attachments, ...current]); + }; // Command actions stop at the caller's UI boundary; creation mode intentionally has its own applier. const applyCommandActions = (actions: CommandAction[]) => { for (const action of actions) { switch (action.type) { case "clear-input": - if (!editCancelled()) setInput(""); + if (!commandEditSession) setInput(""); break; case "reset-input-height": if (inputRef.current) inputRef.current.style.height = ""; @@ -2087,7 +2166,7 @@ const ChatInputInner: React.FC = (props) => { setSendingCount((count) => count + (action.sending ? 1 : -1)); break; case "clear-attachments": - if (!editCancelled()) setAttachments([]); + if (!commandEditSession) setAttachments([]); break; case "detach-reviews": if (variant === "workspace") props.onDetachAllReviews?.(); @@ -2104,6 +2183,7 @@ const ChatInputInner: React.FC = (props) => { break; case "cancel-edit": // Emitted once an editing command (/compact) was accepted: the edit is complete. + commandSnapshot = null; restoredPreEditDraft = restorePreEditDraftAfterSend(commandEditSession, true); if (isOpenEditOrNone(commandEditSession)) commandOnCancelEdit?.(); break; @@ -2114,10 +2194,15 @@ const ChatInputInner: React.FC = (props) => { } }; - let result = await processSlashCommand(parsed, commandEnv); + // A rejected phase settles no disposition: an editing command's input comes back once. + const restoreOnReject = (error: unknown): never => { + putBackCommandSnapshot(); + throw error; + }; + let result = await processSlashCommand(parsed, commandEnv).catch(restoreOnReject); while (result.kind === "phase") { applyCommandActions(result.actions); - result = await result.continue(); + result = await result.continue().catch(restoreOnReject); } applyCommandActions(result.actions); if (result.backgroundTask) { @@ -2131,13 +2216,15 @@ const ChatInputInner: React.FC = (props) => { if (!restoredPreEditDraft) setDraftReviews(null); break; case "restore": - setInput(restoreInput); + if (commandEditSession) putBackCommandSnapshot(); + else setInput(restoreInput); break; case "restore-if-empty": // Async phases can outlive the invoking render, so check the live // draft: the getDraft closure captured here still reports // this render's input and would refuse to restore over a newer draft. - if (getDraftStore().getText(draftScope).trim().length === 0) { + if (commandEditSession && !getEditDraft()?.text.trim()) putBackCommandSnapshot(); + else if (!commandEditSession && getDraftStore().getText(draftScope).trim().length === 0) { setInput(restoreInput); } else { setDraftReviews(null); @@ -2184,8 +2271,13 @@ const ChatInputInner: React.FC = (props) => { ); // Every outcome and the retry affordance are scoped to THIS edit: once the user cancels it - // or edits another row, a late outcome or a stale toast must not touch the new edit. - const isSameEdit = () => editingMessageIdRef.current === editMessageId; + // or edits another row, a late outcome or a stale toast must not touch the new edit (by + // session: a row reopened after Cancel, or an unmounted composer, is not this edit). + const refreshSession = editSessionRef.current; + const isSameEdit = () => + refreshSession !== null && + editSessionRef.current === refreshSession && + editingMessageIdRef.current === editMessageId; patchEditing({ preconditionInvalidated: true, pendingReconfirmation: undefined }); setEditRefreshRetry(null); @@ -2216,7 +2308,7 @@ const ChatInputInner: React.FC = (props) => { case "failed": // Draft and invalidated state stay; a failure is never read as exhausted history. if (!isMountedRef.current || !isSameEdit()) return; - setEditRefreshRetry({ editMessageId, precondition }); + setEditRefreshRetry({ editMessageId, precondition, session: refreshSession }); setToast({ id: Date.now().toString(), type: "error", @@ -2665,17 +2757,21 @@ const ChatInputInner: React.FC = (props) => { if (taken === null) return; const { text } = taken; taken = null; + // An edit that ended meanwhile (switch, Cancel, lost target): after the unsent draft. + if (!isLiveEdit(editSessionForSend)) { + return appendSnapshotToDraft({ text, attachments: sendAttachments }); + } // Put the text back unless the composer holds it again as a whole block: the clear can // miss (the draft was replaced while the send was prepared), or the text can come back // after it (another window that still held it saved it, #5501). A match inside a longer // line is the user's own text, so the sent text comes back beside it: a duplicate // beats a loss. - setInput((current) => + setEditText((current) => hasDraftBlock(current, text) ? current : joinDraftText(text, current) ); // On an id match the sent copy wins: it may be the staged version of a file that // another window saved again as pending (#5501), and a retry must not stage it twice. - setAttachments((current) => { + setEditAttachments((current) => { const sentById = new Map( sendAttachments.map((attachment) => [attachment.id, attachment]) ); @@ -2880,6 +2976,9 @@ const ChatInputInner: React.FC = (props) => { } } + // The composer unmounted or the target was lost meanwhile: the edit's text moves to the + // draft, so sending it would send it twice. A cancelled edit is still sent (#5226). + if (editSessionForSend?.settled === false && !isLiveEdit(editSessionForSend)) return; if (editMessageForSend) { setOptimisticallyDismissedEditId(editMessageForSend.id); } @@ -2890,9 +2989,11 @@ const ChatInputInner: React.FC = (props) => { // Clear only what this send took: a draft restored meanwhile (an edit completing while // this send resolved its options) stays in the composer (#5226). const sentAttachmentIds = new Set([...attachments, ...sendAttachments].map(({ id }) => id)); + // Only this send's own edit loses what it took: text typed while it prepared stays. + const takesFromEdit = !tracksSend && editSessionRef.current === editSessionForSend; if (!tracksSend) { - setInput((current) => removeSentText(current, input)); taken = { text: input }; + if (takesFromEdit) setEditText((current) => removeSentText(current, input)); } // Likewise for notes: drop the override this send captured, keeping notes an edit's // completion put into it meanwhile. @@ -2901,8 +3002,8 @@ const ChatInputInner: React.FC = (props) => { const remaining = current.filter((review) => !preSendReviews?.includes(review)); return remaining.length > 0 ? remaining : null; }); - if (!tracksSend) { - setAttachments((current) => current.filter(({ id }) => !sentAttachmentIds.has(id))); + if (takesFromEdit) { + setEditAttachments((current) => current.filter(({ id }) => !sentAttachmentIds.has(id))); } setHideReviewsDuringSend(true); // Clear inline height style - VimTextArea's useLayoutEffect will handle sizing @@ -2958,9 +3059,11 @@ const ChatInputInner: React.FC = (props) => { // The rows this edit would delete changed after its evidence was captured. Stay in // edit mode with the draft, block Send, and re-read the transcript so the user can // review it and send again explicitly (never automatically). + // Only for the same edit: with no open edit there is nothing to refresh. if ( result.error.type === "history-changed" && editMessageForSend && + isLiveEdit(editSessionForSend) && sendOptions.historyEditPrecondition ) { editConflictToastRef.current = { @@ -3451,7 +3554,10 @@ const ChatInputInner: React.FC = (props) => { )} - +
{/* Editing indicator - workspace only */} @@ -3471,6 +3577,7 @@ const ChatInputInner: React.FC = (props) => { type="button" onClick={() => { setToast(null); + if (editRefreshRetry.session !== editSessionRef.current) return; startEditTranscriptRefresh( editRefreshRetry.editMessageId, editRefreshRetry.precondition diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index b01dd5c922c..b64f190cbbc 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -24,6 +24,10 @@ export interface ChatInputAPI { restoreDraft: (pending: PendingUserMessage, options?: { retainedSendIds?: string[] }) => void; appendText: (text: string) => void; prependText: (text: string) => void; + /** Append where the composer shows it: the visible edit, else the draft (quotes, inserts). */ + appendToVisibleInput: (text: string) => void; + /** Whether the composer shows an open edit; inserts that cannot join it are refused then. */ + isEditVisible: () => boolean; } export interface WorkspaceCreatedOptions { diff --git a/src/browser/features/ChatInput/useComposerAttachments.ts b/src/browser/features/ChatInput/useComposerAttachments.ts index f396b7d1093..fb6a1d53ab9 100644 --- a/src/browser/features/ChatInput/useComposerAttachments.ts +++ b/src/browser/features/ChatInput/useComposerAttachments.ts @@ -21,6 +21,8 @@ interface UseComposerAttachmentsOptions { value: ChatAttachment[] | ((previous: ChatAttachment[]) => ChatAttachment[]) ) => void; editingMessage: boolean; + /** Live check, read when a file lands: the composer may show an edit by then. */ + isEditVisible: () => boolean; pushToast: PushToast; } function getBaseMediaType(mediaType: string): string { @@ -98,7 +100,9 @@ export function useComposerAttachments(options: UseComposerAttachmentsOptions) { ) ).then((outcomes) => { const successes = outcomes.flatMap((outcome) => (outcome.ok ? outcome.attachments : [])); - if (successes.length > 0) { + // An add that started before an edit opened lands in neither the edit nor the draft. + if (successes.length > 0 && options.isEditVisible()) rejectEditAttachment(); + else if (successes.length > 0) { setAttachments((previous) => [...previous, ...successes]); showResizeToast(successes); } diff --git a/src/browser/features/ChatInput/useComposerDraft.ts b/src/browser/features/ChatInput/useComposerDraft.ts index 3a60186b16a..99e46f47de9 100644 --- a/src/browser/features/ChatInput/useComposerDraft.ts +++ b/src/browser/features/ChatInput/useComposerDraft.ts @@ -7,6 +7,7 @@ import { } from "@/browser/stores/DraftStore"; import type { ReviewNoteDataForDisplay } from "@/common/types/message"; import type { Review } from "@/common/types/review"; +import { joinDraftText } from "@/common/utils/composerDraftText"; import { DRAFT_ID_PATTERN } from "@/constants/drafts"; import type { ChatAttachment } from "./ChatAttachments"; import type { Toast } from "./ChatInputToast"; @@ -43,6 +44,25 @@ export function getComposerDraftScope(options: { : defaultCreationDraftScope(options.creationProjectPath); } +/** An open edit's text and files. They live only in the composer that opened the edit. */ +export interface EditDraft { + id: string; + text: string; + attachments: ChatAttachment[]; +} + +/** Appends an edit's text after the draft in `scope`, and its files whose ids are missing. */ +function appendToStoredDraft(scope: DraftStoreScope, snapshot: Omit) { + const store = getDraftStore(); + if (snapshot.text.trim()) + store.setText(scope, (current) => joinDraftText(current, snapshot.text)); + if (snapshot.attachments.length === 0) return; + store.setAttachments(scope, (current) => { + const ids = new Set(current.map(({ id }) => id)); + return [...current, ...snapshot.attachments.filter(({ id }) => !ids.has(id))]; + }); +} + export function useComposerDraft(options: UseComposerDraftOptions) { const { attachedReviews, pushToast } = options; const draftStore = getDraftStore(); @@ -61,6 +81,17 @@ export function useComposerDraft(options: UseComposerDraftOptions) { const setAttachments = ( value: ChatAttachment[] | ((previous: ChatAttachment[]) => ChatAttachment[]) ) => draftStore.setAttachments(draftScope, value); + // The open edit's buffer: an edit never reaches the shared, persisted draft, so a reload or a + // second window keeps the unsent draft (#5672, #5571). The ref is the live value. + const [editDraft, setEditDraftState] = useState(null); + const editDraftRef = useRef(null); + const writeEditDraft = (next: EditDraft | null) => { + editDraftRef.current = next; + setEditDraftState(next); + }; + const updateEditDraft = (update: (current: EditDraft) => EditDraft) => { + if (editDraftRef.current) writeEditDraft(update(editDraftRef.current)); + }; const pushToastRef = useRef(pushToast); pushToastRef.current = pushToast; const { variant, workspaceId, creationProjectPath, pendingDraftId } = options; @@ -86,6 +117,9 @@ export function useComposerDraft(options: UseComposerDraftOptions) { }); return () => { unsubscribeSaveErrors(); + // A switch or transcript-only unmounts the composer with its edit open: no text is lost. + if (editDraftRef.current) appendToStoredDraft(scope, editDraftRef.current); + editDraftRef.current = null; // Leaving this draft (workspace switch, route change, unmount): write it now rather than // after the debounce. A failure keeps the change in the store, which retries it. getDraftStore() @@ -140,8 +174,32 @@ export function useComposerDraft(options: UseComposerDraftOptions) { setInput(next.text); setAttachments(next.attachments); }; + // The only edit operations that write the normal draft: after it, once. + const consumeEditDraftIntoDraft = () => { + if (editDraftRef.current) appendToStoredDraft(draftScope, editDraftRef.current); + writeEditDraft(null); + }; return { draftScope, + editDraft, + getEditDraft: () => editDraftRef.current, + beginEditDraft: writeEditDraft, + setEditText: (value: string | ((previous: string) => string)) => + updateEditDraft((current) => ({ + ...current, + text: typeof value === "function" ? value(current.text) : value, + })), + setEditAttachments: ( + value: ChatAttachment[] | ((previous: ChatAttachment[]) => ChatAttachment[]) + ) => + updateEditDraft((current) => ({ + ...current, + attachments: typeof value === "function" ? value(current.attachments) : value, + })), + dropEditDraft: () => writeEditDraft(null), + consumeEditDraftIntoDraft, + appendSnapshotToDraft: (snapshot: Omit) => + appendToStoredDraft(draftScope, snapshot), input, setInput, payloadsLoaded: draft.payloadsLoaded, diff --git a/src/browser/hooks/useAIViewKeybinds.test.tsx b/src/browser/hooks/useAIViewKeybinds.test.tsx index 1f9bca52704..656264b5210 100644 --- a/src/browser/hooks/useAIViewKeybinds.test.tsx +++ b/src/browser/hooks/useAIViewKeybinds.test.tsx @@ -315,6 +315,8 @@ describe("useAIViewKeybinds", () => { restoreDraft: () => undefined, appendText: () => undefined, prependText: () => undefined, + appendToVisibleInput: () => undefined, + isEditVisible: () => false, }, }; renderUseAIViewKeybinds({ From a77bdf87d21cd47f32e1801952d4514de3e03952 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 14:13:46 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=A4=96=20tests:=20settle=20the=20edit?= =?UTF-8?q?=20tests=20against=20the=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T10: edit in the other workspace before coming back, so ChatPane stays mounted while the send is pending. - T17(b): hold the edit send before the backend (its target still shows); the busy stream changes the history, so the send is refused. - T21: the rejected phase still leaves `void handleSend()` unhandled, as on main; the test stops that rejection at the send's outer wrapper. - T22a: install DOMRect for the context-menu popover. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 50 +++++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 56d29a42876..7980b4be5f6 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -21,6 +21,7 @@ import { } from "@/browser/stores/WorkspaceStore"; import { createTestApiClient } from "@/browser/testUtils"; import * as chatCommands from "@/browser/utils/chatCommands"; +import * as controlFlow from "@/browser/utils/compilerSafeControlFlow"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; @@ -914,6 +915,8 @@ async function quoteTranscriptText(app: AppHarness, text: string) { if (!element) throw new Error(`Transcript text "${text}" not found`); return element; }, LOAD_TOLERANT_WAIT); + // PositionedMenu anchors its popover with DOMRect, which tests/ui/dom.ts does not install. + if (typeof DOMRect === "undefined") Object.assign(globalThis, { DOMRect: window.DOMRect }); fireEvent.contextMenu(target); const quote = await waitFor(() => { const item = [...document.body.querySelectorAll("button")].find((button) => @@ -1316,10 +1319,12 @@ describe("An edit send that settles after its composer unmounted", () => { await app.chat.expectTranscriptNotContains("first message", LOAD_TOLERANT_WAIT.timeout); } - // Back in this workspace while the send is pending: Edit stays refused (#5226). - await switchAwayAndBack(app, other); + // An edit in the other workspace; back here while the send is pending, Edit stays + // refused (#5226); then the other workspace's edit again. ChatPane stays mounted. + await showWorkspace(app, other.id, other.name); + await editRow(app, "other message"); + await showWorkspace(app, app.workspaceId, app.metadata.name); await expectEditRefused(app, targetShown ? "first message" : "edited message"); - await showWorkspace(app, other.id, other.name); await editRow(app, "other message"); typeIntoEdit(editTextarea(app)!, "edited other message"); @@ -1331,6 +1336,7 @@ describe("An edit send that settles after its composer unmounted", () => { fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); await showWorkspace(app, app.workspaceId, app.metadata.name); + await app.chat.expectTranscriptNotContains("first message", LOAD_TOLERANT_WAIT.timeout); await app.chat.expectStreamComplete(); const editedRows = useWorkspaceStoreRaw() .getWorkspaceState(app.workspaceId) @@ -1599,9 +1605,14 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => const app = await createAppHarness({ branchPrefix: "edit-queued-card-during-send" }); try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - await startEditWithUnsentDraft(app, scope); - const sends = holdEditSends(app, "reply-only"); - await sendEdit(app, "[mock:wait-start] edited message", "first message"); + const textarea = await startEditWithUnsentDraft(app, scope); + // Held before the backend sees it: the edit's target still shows (section 3, step 11). + // The busy stream below changes the history, so the backend would refuse the edit anyway. + const sends = holdEditSends(app, "refuse"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); await queueBehindHeldStream(app, "queued Q", "queued note"); await editQueuedMessage(app); await waitFor( @@ -1619,9 +1630,14 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => ).toHaveLength(1) ); - sends.release(); + // The refused edit opens again with its text, and "Q, U" waits behind it. app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); await app.chat.expectStreamComplete(); + sends.release(); + await waitFor( + () => expect(editTextarea(app)?.value).toBe("edited message"), + LOAD_TOLERANT_WAIT + ); await settleAsyncWork(); await expectDraft(app, scope, joinDraftText("queued Q", "unsent draft"), ["unsent.txt"]); sends.spy.mockRestore(); @@ -1718,11 +1734,30 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => } : result; }); + // The rejection still leaves the composer's send unhandled (`void handleSend()`), as on + // main. Only the restore is under test, so the send's outer wrapper stops it there. + const realFinally = controlFlow.runWithFinally; + const rejections: unknown[] = []; + const finallySpy = jest.spyOn(controlFlow, "runWithFinally").mockImplementation((async ( + body: () => Promise, + cleanup: () => void + ) => { + try { + return await realFinally(body, cleanup); + } catch (error) { + if (!(error instanceof Error) || error.message !== "compaction request rejected") { + throw error; + } + rejections.push(error); + return undefined; + } + }) as typeof realFinally); typeIntoEdit(textarea, "/compact -t 500"); await waitFor(() => expect(textarea.value).toBe("/compact -t 500")); fireEvent.keyDown(textarea, { key: "Enter" }); await waitFor(() => expect(commandSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); await settleAsyncWork(); + expect(rejections).toHaveLength(1); await waitFor( () => expect(editTextarea(app)?.value).toBe("/compact -t 500"), LOAD_TOLERANT_WAIT @@ -1730,6 +1765,7 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => expect(occurrences(composerText(app), "edit-file.md")).toBe(1); await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); commandSpy.mockRestore(); + finallySpy.mockRestore(); } finally { await app.dispose(); } From 7d4dd465f5de233549b2f6e2aa989c6aacdc65d9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 14:37:12 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=A4=96=20tests:=20hold=20T22(d)'s=20e?= =?UTF-8?q?dit=20send=20before=20the=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the edit's target still shown, the edit keeps its own buffer behind the shown unsent draft, so the quote test now tells "the visible edit" apart from "the real target" (M25). T17(b) uses the same helper. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 32 ++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 7980b4be5f6..471c95351c7 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -852,6 +852,17 @@ function holdEditSends( const editRequests = (spy: SendSpy) => spy.mock.calls.filter(([, , options]) => options.editMessageId !== undefined).length; +/** Send the open edit as `text`, held before the backend sees it: its target still shows. */ +async function startHeldEditSend(app: AppHarness, text: string, mode: "accept" | "refuse") { + const sends = holdEditSends(app, mode); + const textarea = editTextarea(app)!; + typeIntoEdit(textarea, text); + await waitFor(() => expect(textarea.value).toBe(text)); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + return sends; +} + /** Let pending promise continuations (a late send reply, a refresh outcome) run. */ async function settleAsyncWork() { await act(async () => { @@ -1605,14 +1616,10 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => const app = await createAppHarness({ branchPrefix: "edit-queued-card-during-send" }); try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - const textarea = await startEditWithUnsentDraft(app, scope); - // Held before the backend sees it: the edit's target still shows (section 3, step 11). - // The busy stream below changes the history, so the backend would refuse the edit anyway. - const sends = holdEditSends(app, "refuse"); - typeIntoEdit(textarea, "edited message"); - await waitFor(() => expect(textarea.value).toBe("edited message")); - fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(editRequests(sends.spy)).toBe(1), LOAD_TOLERANT_WAIT); + await startEditWithUnsentDraft(app, scope); + // The edit's target still shows (section 3, step 11). The busy stream below changes the + // history, so the backend would refuse the edit anyway. + const sends = await startHeldEditSend(app, "edited message", "refuse"); await queueBehindHeldStream(app, "queued Q", "queued note"); await editQueuedMessage(app); await waitFor( @@ -1806,8 +1813,8 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => } await typeUnsentDraft(app, scope); await editRow(app, "second message"); - const sends = holdEditSends(app, "reply-only"); - await sendEdit(app, "[mock:wait-start] edited message", "Mock response: second message"); + // Held before the backend: the edit's own buffer still exists behind the shown draft. + const sends = await startHeldEditSend(app, "edited message", "accept"); await quoteTranscriptText(app, "Mock response: first message"); await waitFor(() => { const text = getDraftStore().getText(scope); @@ -1816,7 +1823,10 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => }, LOAD_TOLERANT_WAIT); expect(messageTextarea(app).value).toBe(getDraftStore().getText(scope)); sends.release(); - app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectTranscriptContains( + "Mock response: edited message", + LOAD_TOLERANT_WAIT.timeout + ); await app.chat.expectStreamComplete(); sends.spy.mockRestore(); } finally { From e8cc569252b4dbc4d83e90ebaa00db6ec5f584c2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 14:41:16 +0000 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A4=96=20tests:=20the=20reload-during?= =?UTF-8?q?-edit=20repro=20is=20no=20longer=20a=20known=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix keeps an open edit out of the shared draft, so the #5672 repro passes. It loses its known-failure tag and header. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts b/tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts index 11122f84ba1..d74dba585aa 100644 --- a/tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts +++ b/tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts @@ -1,5 +1,3 @@ -// Known failure, open issue #5672: a reload during a message edit leaves the edit text in the -// composer as the new-message draft, and the unsent draft is lost. Fails until #5672 is fixed. import { test } from "@e2e-dev/web"; import { expect } from "e2e"; import { openPlayground, sendMessageForEdit, WORKSPACE_TITLE } from "./helpers"; @@ -9,7 +7,7 @@ const messageText = () => `5672 message to edit ${Date.now()}`; test( "a reload during an edit keeps the unsent draft", - { tags: ["bugbash", "known-failure", "5672"] }, + { tags: ["bugbash", "5672"] }, async ({ app, screen, browser }) => { await openPlayground(app, screen, browser); const MESSAGE = messageText(); From eda9ae1e3fed7089a2e791ece5228ee2c9bb3059 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 19:16:57 +0000 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20declare=20the=20edit?= =?UTF-8?q?=20refs=20before=20the=20unmount=20cleanup=20that=20clears=20th?= =?UTF-8?q?em?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Compiler skips ChatInputInner when it writes a ref before its declaration. --- src/browser/features/ChatInput/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 458a8135acc..99aee891474 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -551,6 +551,12 @@ const ChatInputInner: React.FC = (props) => { const onAcceptRestoredHeldInputs = variant === "workspace" ? props.onAcceptRestoredHeldInputs : undefined; + // The latest edit's session. Settled explicitly: the edit target also leaves the live + // transcript when the accepted edit replaces it (possibly before the send returns), and that + // is not a cancel. + const editSessionRef = useRef(null); + // The edit target the entry effect last applied (see the edit-entry effect). + const appliedEditIdRef = useRef(null); // Creation sends can resolve after navigation; guard draft clears on unmounted inputs. const isMountedRef = useRef(true); useEffect(() => { @@ -1272,10 +1278,6 @@ const ChatInputInner: React.FC = (props) => { ] ); - // The latest edit's session. Settled explicitly: the edit target also leaves the live - // transcript when the accepted edit replaces it (possibly before the send returns), and that - // is not a cancel. - const editSessionRef = useRef(null); // Still this composer's open, unsettled edit and ChatPane's target (none after an unmount). const isLiveEdit = useCallback( (session: EditSession | null) => @@ -1452,7 +1454,6 @@ const ChatInputInner: React.FC = (props) => { // Runs once per edit target: the draft callbacks change identity as the user types, and // re-applying would clobber the in-progress edit text. The applied-id ref makes that // explicit instead of hiding the callbacks from the dependency list. - const appliedEditIdRef = useRef(null); const draftPayloadsLoaded = draft.payloadsLoaded; useEffect(() => { // An edit that ended without a send or Cancel (second Edit, deleted row, target not found) From a12dad2c3fb377a0c00ef9cfd8ce01d5ba7f259c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 21:12:46 +0000 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20an=20editing=20comman?= =?UTF-8?q?d=20consumes=20only=20the=20parts=20of=20the=20edit=20it=20clea?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text-only command such as /vim emits clear-input only. As on main, the edit keeps its files. --- src/browser/features/ChatInput/index.tsx | 13 +++++++++ tests/ui/chat/editKeepsUnsentDraft.test.ts | 32 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 99aee891474..29349edec74 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2144,12 +2144,22 @@ const ChatInputInner: React.FC = (props) => { setEditText((current) => joinDraftText(snapshot.text, current)); setEditAttachments((current) => [...snapshot.attachments, ...current]); }; + // A command consumes only what it clears: /vim clears the text, and the edit keeps its files. + let clearedText = false; + let clearedFiles = false; + const putBackUncleared = () => { + if (!commandSnapshot) return; + if (clearedText) commandSnapshot = { ...commandSnapshot, text: "" }; + if (clearedFiles) commandSnapshot = { ...commandSnapshot, attachments: [] }; + putBackCommandSnapshot(); + }; // Command actions stop at the caller's UI boundary; creation mode intentionally has its own applier. const applyCommandActions = (actions: CommandAction[]) => { for (const action of actions) { switch (action.type) { case "clear-input": if (!commandEditSession) setInput(""); + else clearedText = true; break; case "reset-input-height": if (inputRef.current) inputRef.current.style.height = ""; @@ -2168,6 +2178,7 @@ const ChatInputInner: React.FC = (props) => { break; case "clear-attachments": if (!commandEditSession) setAttachments([]); + else clearedFiles = true; break; case "detach-reviews": if (variant === "workspace") props.onDetachAllReviews?.(); @@ -2214,6 +2225,7 @@ const ChatInputInner: React.FC = (props) => { case "consume": // Commands clear the composer through their own clear-input actions; // clearing again here would wipe a draft typed while phases ran. + putBackUncleared(); if (!restoredPreEditDraft) setDraftReviews(null); break; case "restore": @@ -2228,6 +2240,7 @@ const ChatInputInner: React.FC = (props) => { else if (!commandEditSession && getDraftStore().getText(draftScope).trim().length === 0) { setInput(restoreInput); } else { + putBackUncleared(); setDraftReviews(null); } break; diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 471c95351c7..5f591e826f3 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -1883,4 +1883,36 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => await app.dispose(); } }, 120_000); + + // T24 (#5893 review): /vim consumes its input with clear-input only. As on main, the edit + // keeps its files: only what a command clears belongs to it. + test("a text-only command typed into an edit keeps the edit's file", async () => { + const app = await createAppHarness({ branchPrefix: "edit-command-keeps-file" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + // A provider file: commands other than /compact refuse staged files before they run. + getDraftStore().setAttachments(scope, [ + { + kind: "provider", + id: "file-edit", + url: "data:text/plain;base64,ZWRpdA==", + mediaType: "text/plain", + filename: "edit.txt", + }, + ]); + const textarea = await startEditWithUnsentDraft(app, scope); + expect(occurrences(composerText(app), "edit.txt")).toBe(1); + // The trailing space closes the command suggestions, so Enter sends. + typeIntoEdit(textarea, "/vim "); + await waitFor(() => expect(textarea.value).toBe("/vim ")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editTextarea(app)?.value).toBe(""), LOAD_TOLERANT_WAIT); + await settleAsyncWork(); + expect(editTextarea(app)).not.toBeNull(); + expect(occurrences(composerText(app), "edit.txt")).toBe(1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + } finally { + await app.dispose(); + } + }, 120_000); }); From e1cddc014a26e1cdc3e1c672425cced184f0478f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 21:20:49 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=A4=96=20tests:=20a=20held=20command?= =?UTF-8?q?=20returns=20the=20parts=20of=20an=20edit=20it=20did=20not=20cl?= =?UTF-8?q?ear,=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 139 +++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 5f591e826f3..f07f1850f36 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -1915,4 +1915,143 @@ describe("Edit sends, restores and inserts while the unsent draft waits", () => await app.dispose(); } }, 120_000); + + // T25 (#5893 review evidence): a held, scripted command shows where the parts of an edit that + // the command did not clear go, once, for each disposition and edit state. + type HeldCommandEnd = "edit open" | "new text typed" | "edit ended by a switch"; + const scriptedCommand = ( + actions: chatCommands.CommandAction[], + inputDisposition: "consume" | "restore-if-empty" + ) => { + let release = () => {}; + const gate = new Promise((resolve) => (release = resolve)); + const spy = jest.spyOn(chatCommands, "processSlashCommand").mockImplementation(() => + Promise.resolve({ + kind: "phase", + actions, + continue: () => gate.then(() => ({ kind: "complete", actions: [], inputDisposition })), + }) + ); + return { spy, release: () => release() }; + }; + async function runHeldCommand( + prefix: string, + actions: chatCommands.CommandAction[], + inputDisposition: "consume" | "restore-if-empty", + end: HeldCommandEnd, + expectAfter: (app: AppHarness, scope: DraftScope) => Promise + ) { + const app = await createAppHarness({ branchPrefix: prefix }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = + end === "edit ended by a switch" ? await addOtherWorkspace(app, `${prefix}-o`) : null; + getDraftStore().setAttachments(scope, [ + { + kind: "provider", + id: "file-edit", + url: "data:text/plain;base64,ZWRpdA==", + mediaType: "text/plain", + filename: "edit.txt", + }, + ]); + const textarea = await startEditWithUnsentDraft(app, scope); + const command = scriptedCommand(actions, inputDisposition); + typeIntoEdit(textarea, "/vim "); + await waitFor(() => expect(textarea.value).toBe("/vim ")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(command.spy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); + // The command took the edit's text and files when it started. + await waitFor(() => expect(editTextarea(app)?.value).toBe(""), LOAD_TOLERANT_WAIT); + expect(occurrences(composerText(app), "edit.txt")).toBe(0); + if (end === "new text typed") { + typeIntoEdit(editTextarea(app)!, "new words"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("new words")); + } + if (other) { + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + } + command.release(); + await settleAsyncWork(); + await expectAfter(app, scope); + command.spy.mockRestore(); + } finally { + await app.dispose(); + } + } + const expectEdit = async (app: AppHarness, text: string, files: number) => { + await waitFor(() => expect(editTextarea(app)?.value).toBe(text), LOAD_TOLERANT_WAIT); + expect(occurrences(composerText(app), "edit.txt")).toBe(files); + }; + const clearsText: chatCommands.CommandAction[] = [{ type: "clear-input" }]; + const cases: { + disposition: "consume" | "restore-if-empty"; + end: HeldCommandEnd; + expectAfter: (app: AppHarness, scope: DraftScope) => Promise; + }[] = [ + { + disposition: "consume", + end: "new text typed", + expectAfter: async (app, scope) => { + await expectEdit(app, "new words", 1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + }, + }, + { + disposition: "consume", + end: "edit ended by a switch", + expectAfter: (app, scope) => + expectDraft(app, scope, "unsent draft", ["unsent.txt", "edit.txt"]), + }, + { + disposition: "restore-if-empty", + end: "edit open", + expectAfter: async (app, scope) => { + await expectEdit(app, "/vim ", 1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + }, + }, + { + disposition: "restore-if-empty", + end: "new text typed", + expectAfter: async (app, scope) => { + await expectEdit(app, "new words", 1); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + }, + }, + { + disposition: "restore-if-empty", + end: "edit ended by a switch", + expectAfter: (app, scope) => + expectDraft(app, scope, joinDraftText("unsent draft", "/vim "), ["unsent.txt", "edit.txt"]), + }, + ]; + for (const testCase of cases) { + test(`a held text-only command (${testCase.disposition}, ${testCase.end}) returns the edit's file once`, async () => { + await runHeldCommand( + `held-${testCase.disposition === "consume" ? "c" : "r"}-${testCase.end.split(" ")[0]}-${testCase.end.split(" ")[1]}`, + clearsText, + testCase.disposition, + testCase.end, + testCase.expectAfter + ); + }, 120_000); + } + + // T26 (#5893 review evidence): a command that clears the files keeps them cleared. + for (const end of ["edit open", "edit ended by a switch"] as const) { + test(`a held command that clears attachments (${end}) brings no file back`, async () => { + await runHeldCommand( + `held-clr-${end.split(" ")[1]}`, + [{ type: "clear-input" }, { type: "clear-attachments" }], + "consume", + end, + async (app, scope) => { + if (end === "edit open") await expectEdit(app, "", 0); + await expectDraft(app, scope, "unsent draft", ["unsent.txt"]); + } + ); + }, 120_000); + } });