From 33c32602daea3970ac2a8bb3ad332ec563b57c0d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 21:07:31 +0000 Subject: [PATCH 01/15] tests: repro #5672 and #5571 (edit text reaches the shared draft) --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index ccbb9241ba..051ee46de1 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -11,7 +11,8 @@ 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 { createTestApiClient } from "@/browser/testUtils"; import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; import type { ReviewNoteData } from "@/common/types/review"; @@ -72,11 +73,55 @@ async function expectUnsentDraftKept(app: AppHarness, scope: DraftScope) { expect(saved.attachments.map(({ id }) => id)).toEqual(["file-unsent"]); } +/** 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; +} + describe("Completing an edit of an older message", () => { beforeAll(async () => { await preloadTestModules(); }); + // The edit text lives in this window's memory only: the shared draft keeps the unsent draft, + // so a reload (#5672) and a second window (#5571) both see the unsent draft, never the edit. + test("a reload during an edit keeps the unsent draft (#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 }; + await startEditWithUnsentDraft(app, scope); + await getDraftStore().flush(scope); + expect((await app.env.services.draftService.get(scope)).text).toBe("unsent draft"); + reloaded = await otherRenderer(app); + expect(reloaded.getText(scope)).toBe("unsent draft"); + expect(reloaded.getView(scope).attachments.map(({ id }) => id)).toEqual(["file-unsent"]); + } finally { + reloaded?.setClient(null); + await app.dispose(); + } + }, 120_000); + + test("an edit in one window does not reach another window's composer (#5571)", async () => { + const app = await createAppHarness({ branchPrefix: "edit-other-window" }); + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const secondWindow = await otherRenderer(app); + try { + await startEditWithUnsentDraft(app, scope); + await getDraftStore().flush(scope); + // The second window saw the unsent draft arrive; the edit text never follows it. + await waitFor(() => expect(secondWindow.getText(scope)).toBe("unsent draft")); + await getDraftStore().flush(scope); + expect(secondWindow.getText(scope)).toBe("unsent draft"); + } finally { + secondWindow.setClient(null); + await app.dispose(); + } + }, 120_000); + test("keeps the unsent draft, with its attachments", async () => { const app = await createAppHarness({ branchPrefix: "edit-keeps-draft" }); try { From d5bb8273b199fccffe322f1ff84b891fb918c4ec Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 21:24:26 +0000 Subject: [PATCH 02/15] fix: keep the edit text in the composer's memory, out of the shared draft (#5672, #5571) --- src/browser/features/ChatInput/index.tsx | 63 ++++++------ .../features/ChatInput/useComposerDraft.ts | 69 +++++++++++-- ...e.ts => reloadDuringEditKeepsDraft.e2e.ts} | 6 +- .../ui/chat/composerDraftsFormalRepro.test.ts | 96 ++++--------------- tests/ui/chat/editKeepsUnsentDraft.test.ts | 70 +++++++++----- 5 files changed, 154 insertions(+), 150 deletions(-) rename tests/bugbash/repros/{knownFailureReloadDuringEdit.e2e.ts => reloadDuringEditKeepsDraft.e2e.ts} (84%) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 47857198df..08f176148e 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -309,11 +309,13 @@ function pendingChatAttachments( return [...providerAttachments, ...stagedAttachments]; } -/** One edit, from entering edit mode until it is cancelled or its send is accepted (#5226). */ +/** + * One edit, from entering edit mode until it is cancelled or its send is accepted (#5226). Its + * text lives in the composer's memory-only edit buffer (useComposerDraft), so the unsent draft + * is never replaced and needs no snapshot (#5672, #5571). + */ 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; @@ -506,11 +508,12 @@ const ChatInputInner: React.FC = (props) => { workspaceId, creationProjectPath: creationParentProjectPath, pendingDraftId: variant === "creation" ? (props.pendingDraftId ?? undefined) : undefined, + editMessageId: editingMessage?.id, attachedReviews: variant === "workspace" ? (props.attachedReviews ?? []) : [], pushToast, }); const { input, setInput, attachments, setAttachments, draftReviews, setDraftReviews } = draft; - const { getDraft, setDraft } = draft; + const { getDraft, setDraft, getLiveText, beginEditDraft, endEditDraft } = draft; const { reviewOverrideActive, reviewData, reviewIdsForCheck, reviewPanelItems } = draft; const { removeDraftReview, updateDraftReviewNote, draftScope, latestInputValueRef } = draft; const { @@ -1255,7 +1258,8 @@ const ChatInputInner: React.FC = (props) => { const session = editSessionRef.current; if (!session || session.settled || session.id !== editingMessageIdRef.current) return; session.settled = true; - setDraft(session.preEditDraft); + // The edit text is dropped; the composer shows the unsent draft again. + endEditDraft(); setDraftReviews(session.preEditReviews); }; @@ -1271,11 +1275,16 @@ 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]); + // The composer goes back to the unsent draft; what was typed in the edit buffer while the + // send was in flight joins it after, never replacing it. + const typedDuringSend = endEditDraft(); + if (typedDuringSend && typedDuringSend.text.trim().length > 0) { + setInput((current) => joinDraftText(current, typedDuringSend.text)); + } + if (typedDuringSend && typedDuringSend.attachments.length > 0) { + setAttachments((current) => [...current, ...typedDuringSend.attachments]); } if (preEditReviews !== null) { if ((dropEditReviews || draftReviewsRef.current === null) && onAddReviewForRestore) { @@ -1397,35 +1406,27 @@ const ChatInputInner: React.FC = (props) => { }; }, [focusMessageInput, openModelSelector]); - // When entering editing mode, save current draft and populate with message content. - // 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. + // When entering editing mode, fill the edit buffer with the message content; the unsent draft + // stays as it is. 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(() => { if (!editingMessage) { appliedEditIdRef.current = null; return; } if (appliedEditIdRef.current === editingMessage.id) return; - if (!draftPayloadsLoaded) { - // Hydrated attachments have no payloads yet (the draft shows none). Snapshotting now would - // save an attachment-less draft on cancel, and the edit's full replacement would end the - // load. Enter edit mode once they load (re-requested here in case an earlier load failed). - getDraftStore() - .ensurePayloads(draftScope) - .catch((error: unknown) => console.warn("Failed to load draft attachments:", error)); - return; - } appliedEditIdRef.current = editingMessage.id; editSessionRef.current = { id: editingMessage.id, - preEditDraft: getDraft(), preEditReviews: draftReviews, settled: false, }; - applyDraftFromPending(editingMessage.pending, `edit-${editingMessage.id}`); + beginEditDraft(editingMessage.id, { + text: editingMessage.pending.content, + attachments: pendingChatAttachments(editingMessage.pending, `edit-${editingMessage.id}`), + }); setDraftReviews(editingMessage.pending.reviews); // Auto-resize textarea and focus setTimeout(() => { @@ -1436,15 +1437,7 @@ const ChatInputInner: React.FC = (props) => { inputRef.current.focus(); } }, 0); - }, [ - editingMessage, - draftPayloadsLoaded, - draftScope, - getDraft, - draftReviews, - applyDraftFromPending, - setDraftReviews, - ]); + }, [editingMessage, draftReviews, beginEditDraft, setDraftReviews]); // Project live workflow run cards for foreground slash invocations after reloads. useEffect(() => { @@ -2137,7 +2130,7 @@ const ChatInputInner: React.FC = (props) => { // 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 (getLiveText().trim().length === 0) { setInput(restoreInput); } else { setDraftReviews(null); diff --git a/src/browser/features/ChatInput/useComposerDraft.ts b/src/browser/features/ChatInput/useComposerDraft.ts index 3a60186b16..bec16c088d 100644 --- a/src/browser/features/ChatInput/useComposerDraft.ts +++ b/src/browser/features/ChatInput/useComposerDraft.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { defaultCreationDraftScope, getDraftStore, @@ -16,6 +16,8 @@ interface UseComposerDraftOptions { workspaceId: string | null; creationProjectPath: string; pendingDraftId?: string; + /** The message being edited, if any: its text lives in the edit buffer below. */ + editMessageId?: string; attachedReviews: Review[]; pushToast: (toast: Omit & { type: Toast["type"] | "info" }) => void; } @@ -43,6 +45,17 @@ export function getComposerDraftScope(options: { : defaultCreationDraftScope(options.creationProjectPath); } +/** The open edit's text and attachments. Memory only: see useComposerDraft. */ +interface EditDraft { + editId: string; + text: string; + attachments: ChatAttachment[]; +} + +type Update = T | ((previous: T) => T); +const applyUpdate = (value: Update, previous: T): T => + typeof value === "function" ? (value as (previous: T) => T)(previous) : value; + export function useComposerDraft(options: UseComposerDraftOptions) { const { attachedReviews, pushToast } = options; const draftStore = getDraftStore(); @@ -50,17 +63,43 @@ export function useComposerDraft(options: UseComposerDraftOptions) { // Drafts live in the in-memory DraftStore, persisted to the backend in the background. The // rendered text never waits for (or depends on) a storage write succeeding (issue 5006). const draft = useDraft(draftScope); - const input = draft.text; - const attachments = draft.attachments; - const setInput = (value: string | ((previous: string) => string)) => - draftStore.setText(draftScope, value); + // While a message is edited, the composer edits this buffer instead of the draft: the edit + // text stays in this window's memory, so a reload keeps the unsent draft (#5672) and another + // window never shows the edit (#5571). A reload drops the edit; that is the chosen tradeoff. + // The ref is the live copy for writes that run after an await; renders read the state. + const [editDraft, setEditDraftState] = useState(null); + const editDraftRef = useRef(null); + const editIdRef = useRef(options.editMessageId); + useLayoutEffect(() => { + editIdRef.current = options.editMessageId; + }); + const writeEditDraft = (next: EditDraft | null) => { + editDraftRef.current = next; + setEditDraftState(next); + }; + // Only the open edit's buffer counts. One left behind by an edit that ended without settling + // (its row was replaced, a workspace switch) is ignored and discarded like a cancelled edit. + const liveEditDraft = () => { + const current = editDraftRef.current; + return current !== null && current.editId === editIdRef.current ? current : null; + }; + const editActive = editDraft !== null && editDraft.editId === options.editMessageId; + const input = editActive ? editDraft.text : draft.text; + const attachments = editActive ? editDraft.attachments : draft.attachments; + const setInput = (value: Update) => { + const edit = liveEditDraft(); + if (edit) writeEditDraft({ ...edit, text: applyUpdate(value, edit.text) }); + else draftStore.setText(draftScope, value); + }; const latestInputValueRef = useRef(input); latestInputValueRef.current = input; // Synchronous: the store applies the change before returning, so a Stop restore can flush it // right after this call (#4448) even if the composer unmounts before the next render. - const setAttachments = ( - value: ChatAttachment[] | ((previous: ChatAttachment[]) => ChatAttachment[]) - ) => draftStore.setAttachments(draftScope, value); + const setAttachments = (value: Update) => { + const edit = liveEditDraft(); + if (edit) writeEditDraft({ ...edit, attachments: applyUpdate(value, edit.attachments) }); + else draftStore.setAttachments(draftScope, value); + }; const pushToastRef = useRef(pushToast); pushToastRef.current = pushToast; const { variant, workspaceId, creationProjectPath, pendingDraftId } = options; @@ -144,7 +183,19 @@ export function useComposerDraft(options: UseComposerDraftOptions) { draftScope, input, setInput, - payloadsLoaded: draft.payloadsLoaded, + /** The live composer text (the open edit's, else the draft's), for code after an await. */ + getLiveText: () => liveEditDraft()?.text ?? draftStore.getText(draftScope), + /** Fill the edit buffer; from now on the composer edits it, not the draft. */ + beginEditDraft: (editId: string, next: { text: string; attachments: ChatAttachment[] }) => + writeEditDraft({ editId, ...next }), + /** Drop the edit buffer and return what it held (text typed during an edit send). */ + endEditDraft: () => { + const edit = editDraftRef.current; + writeEditDraft(null); + return edit; + }, + // An edit's attachments come from its message, complete. + payloadsLoaded: editActive || draft.payloadsLoaded, unresolvedSendCount: draft.unresolvedSendCount, latestInputValueRef, attachments, diff --git a/tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts b/tests/bugbash/repros/reloadDuringEditKeepsDraft.e2e.ts similarity index 84% rename from tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts rename to tests/bugbash/repros/reloadDuringEditKeepsDraft.e2e.ts index 11122f84ba..fd1a406f75 100644 --- a/tests/bugbash/repros/knownFailureReloadDuringEdit.e2e.ts +++ b/tests/bugbash/repros/reloadDuringEditKeepsDraft.e2e.ts @@ -1,5 +1,5 @@ -// 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. +// #5672: a reload during a message edit must keep the unsent draft. The edit text lives in the +// window's memory only, so the reload drops the edit and the composer shows the unsent draft. import { test } from "@e2e-dev/web"; import { expect } from "e2e"; import { openPlayground, sendMessageForEdit, WORKSPACE_TITLE } from "./helpers"; @@ -9,7 +9,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(); diff --git a/tests/ui/chat/composerDraftsFormalRepro.test.ts b/tests/ui/chat/composerDraftsFormalRepro.test.ts index 83c2dfd4d7..1c4505f903 100644 --- a/tests/ui/chat/composerDraftsFormalRepro.test.ts +++ b/tests/ui/chat/composerDraftsFormalRepro.test.ts @@ -201,15 +201,16 @@ describe("formal/composer-drafts: composer text across a failed send", () => { /** * #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. + * and puts what it took back when it fails. Since #5571 the edit text lives in this window's + * memory, so no other window can hold it or save it again: the put-back goes into the edit, and + * what another window saves meanwhile stays in the shared draft. */ describe("formal/composer-drafts: a failed edit's put-back", () => { beforeAll(async () => { await preloadTestModules(); }); - async function startEdit(app: AppHarness, scope: DraftScope) { + async function startEdit(app: AppHarness) { await app.chat.send("first message"); await app.chat.expectTranscriptContains("Mock response: first message", WAIT.timeout); await app.chat.expectStreamComplete(); @@ -227,7 +228,7 @@ describe("formal/composer-drafts: a failed edit's put-back", () => { expect(element.value).toBe("first message"); return element; }, WAIT); - getDraftStore().setText(scope, "edited message"); + fireEvent.change(textarea, { target: { value: "edited message" } }); await waitFor(() => expect(textarea.value).toBe("edited message"), WAIT); return textarea; } @@ -239,10 +240,10 @@ describe("formal/composer-drafts: a failed edit's put-back", () => { ); } - test("does not show the edit's text twice when another window saved it meanwhile", async () => { + test("puts the edit's text back into the edit once, apart from another window's draft", 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) => { + const textarea = await startEdit(app).catch(async (error: unknown) => { await app.dispose(); throw error; }); @@ -250,18 +251,23 @@ describe("formal/composer-drafts: a failed edit's put-back", () => { 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); + await waitFor(() => expect(textarea.value).toBe(""), WAIT); - // 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"; + // A second window saves its own draft while the edit send is in flight. + const saved = "typed 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. + // Target assertion: the edit gets its text back once, and the shared draft holds only the + // other window's text. + await waitFor(() => { + const edit = app.view.container.querySelector( + 'textarea[aria-label="Edit message"]' + ); + expect(edit?.value).toBe("edited message"); + }, WAIT); expect(getDraftStore().getView(scope).text).toBe(saved); await getDraftStore().flush(scope); expect((await app.env.services.draftService.get(scope)).text).toBe(saved); @@ -271,70 +277,4 @@ describe("formal/composer-drafts: a failed edit's put-back", () => { 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); }); diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 051ee46de1..299070c5b2 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -81,6 +81,14 @@ async function otherRenderer(app: AppHarness): Promise { return store; } +/** + * 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(); @@ -98,7 +106,8 @@ describe("Completing an edit of an older message", () => { expect((await app.env.services.draftService.get(scope)).text).toBe("unsent draft"); reloaded = await otherRenderer(app); expect(reloaded.getText(scope)).toBe("unsent draft"); - expect(reloaded.getView(scope).attachments.map(({ id }) => id)).toEqual(["file-unsent"]); + const saved = await app.env.services.draftService.get(scope); + expect(saved.attachments.map(({ id }) => id)).toEqual(["file-unsent"]); } finally { reloaded?.setClient(null); await app.dispose(); @@ -128,7 +137,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" }); @@ -162,7 +171,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); @@ -182,7 +191,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" }); @@ -245,7 +254,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); @@ -305,9 +314,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); @@ -444,23 +453,27 @@ 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 () => { + test("no new edit starts while an edit send is pending; the unsent draft is back once it is accepted", async () => { const app = await createAppHarness({ branchPrefix: "edit-refused-while-pending" }); try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; 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"); - // A second edit (the row's Edit action) and a third (ArrowUp in the empty composer). + // The edit text never replaced the unsent draft: once the edited row is replaced, the + // composer shows the draft again, before the edit's reply. + await app.chat.expectInputValue("unsent draft", LOAD_TOLERANT_WAIT.timeout); + // A second edit (the row's Edit action) and a third (ArrowUp in an emptied composer). await expectEditRefused(app, "edited message"); + await app.chat.typeWithoutSending(""); await act(async () => { fireEvent.keyDown(composerHolding(app, ""), { key: "ArrowUp" }); await new Promise((resolve) => setTimeout(resolve, 50)); }); expect(editTextarea(app)).toBeNull(); - await app.chat.typeWithoutSending("typed meanwhile"); + await app.chat.typeWithoutSending("unsent draft\n\ntyped meanwhile"); replies.release(); app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); @@ -503,7 +516,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); @@ -525,7 +538,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" }); @@ -557,7 +570,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"); @@ -576,25 +589,29 @@ describe("Edit sends racing newer composer input (#5226)", () => { } }, 120_000); - test("a follow-up sent while an edit is pending clears only its own text", async () => { + test("a follow-up sent while an edit is pending gets nothing merged in when the edit completes", async () => { const app = await createAppHarness({ branchPrefix: "edit-followup-keeps-draft" }); try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; 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"); + // The unsent draft is back once the edit is accepted; the user replaces it. + 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 is restored into the composer. replies.release(); + // Edits work again once the edit send settled. await waitFor( - () => expect(getDraftStore().getText(scope)).toBe("unsent draft\n\nfollow-up"), + () => expect(rowEditButton(app, "edited message")?.disabled).toBe(false), LOAD_TOLERANT_WAIT ); + expect(getDraftStore().getText(scope)).toBe("follow-up"); save.release(); await waitFor( @@ -606,7 +623,7 @@ describe("Edit sends racing newer composer input (#5226)", () => { "Mock response: follow-up", LOAD_TOLERANT_WAIT.timeout ); - await expectUnsentDraftKept(app, scope); + await app.chat.expectInputValue("", LOAD_TOLERANT_WAIT.timeout); replies.spy.mockRestore(); save.spy.mockRestore(); } finally { @@ -627,7 +644,9 @@ describe("Edit sends racing newer composer input (#5226)", () => { 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"); + // The pre-edit text never left the draft: it shows once the edit is accepted. + await app.chat.expectInputValue(restoredText, LOAD_TOLERANT_WAIT.timeout); // 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. @@ -638,12 +657,14 @@ 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. (Its text never + // left the draft; sending the follow-up above replaced it.) replies.release(); await waitFor( - () => expect(getDraftStore().getText(scope)).toBe(`${restoredText}\n\nsecond follow-up`), + () => expect(reviewPanelNotes(app).join("\n")).toContain("pre-edit note"), LOAD_TOLERANT_WAIT ); + expect(getDraftStore().getText(scope)).toBe("second follow-up"); save.release(); await waitFor( @@ -655,7 +676,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 @@ -676,7 +697,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", @@ -690,7 +710,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( From 44705bc48cd8a19aae1815e4e9500e21de8a9bd7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 21:41:07 +0000 Subject: [PATCH 03/15] tests: a refused edit send keeps its staged file once, out of the shared draft --- .../ui/chat/composerDraftsFormalRepro.test.ts | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/tests/ui/chat/composerDraftsFormalRepro.test.ts b/tests/ui/chat/composerDraftsFormalRepro.test.ts index 1c4505f903..6b7b9ccc5b 100644 --- a/tests/ui/chat/composerDraftsFormalRepro.test.ts +++ b/tests/ui/chat/composerDraftsFormalRepro.test.ts @@ -26,7 +26,8 @@ import { fireEvent, waitFor } from "@testing-library/react"; import * as fs from "fs/promises"; import * as path from "path"; -import { getDraftStore } from "@/browser/stores/DraftStore"; +import { DraftStore, getDraftStore } from "@/browser/stores/DraftStore"; +import { createTestApiClient } from "@/browser/testUtils"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; import { preloadTestModules } from "../../ipc/setup"; import { createAppHarness, type AppHarness } from "../harness"; @@ -277,4 +278,91 @@ describe("formal/composer-drafts: a failed edit's put-back", () => { await app.dispose(); } }, 120_000); + + // Replaces the pre-#5571 "another window saved the staged copy again" case: no other window + // holds an edit now, but a failed edit send must still neither lose nor duplicate a staged file. + test("keeps a staged attachment in the edit once across a refused send, out of the shared draft", async () => { + const app = await createAppHarness({ branchPrefix: "formal-edit-restore-staged" }); + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const secondWindow = new DraftStore(); + secondWindow.setClient(createTestApiClient(app.env.orpc)); + const stageSpy = jest.spyOn(app.env.services.workspaceService, "stageAttachment"); + const chips = () => + ( + app.view.container.querySelector('[data-component="ChatInputSection"]')?.textContent ?? "" + ).split("notes.md").length - 1; + const unsent = { + kind: "provider" as const, + id: "file-unsent", + url: "data:text/plain;base64,dW5zZW50", + mediaType: "text/plain", + filename: "unsent.txt", + }; + let held: ReturnType | null = null; + try { + await secondWindow.whenReady(); + // The message to edit carries a file staged into the workspace. + 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; + }, WAIT); + fireEvent.change(input, { + target: { files: [new File(["# notes"], "notes.md", { type: "text/markdown" })] }, + }); + await waitFor(() => expect(chips()).toBe(1), WAIT); + await app.chat.send("first message"); + await app.chat.expectTranscriptContains("Mock response: first message", WAIT.timeout); + await app.chat.expectStreamComplete(); + expect(stageSpy).toHaveBeenCalledTimes(1); + // An unsent draft with its own file, then the edit: the edit shows the message's file. + getDraftStore().setText(scope, "unsent draft"); + getDraftStore().setAttachments(scope, [unsent]); + await waitFor(() => expect(secondWindow.getText(scope)).toBe("unsent draft"), WAIT); + fireEvent.click( + await waitFor(() => { + const button = app.view.container.querySelector('button[aria-label="Edit"]'); + if (!button) throw new Error("Edit button not found"); + return button; + }, WAIT) + ); + const textarea = await waitFor(() => { + const element = app.view.container.querySelector( + 'textarea[aria-label="Edit message"]' + ); + if (!element) throw new Error("Edit textarea not found"); + return element; + }, WAIT); + fireEvent.change(textarea, { target: { value: "edited message" } }); + await waitFor(() => expect(chips()).toBe(1), WAIT); + + held = holdSendReplies(app, () => refused()); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(held?.spy).toHaveBeenCalledTimes(1), WAIT); + held.release(); + await expectRefused(app); + // Target assertion: the refused edit keeps its text and the staged file, once, without + // staging it again; the shared draft and the other window keep only the unsent draft. + await waitFor(() => expect(textarea.value).toBe("edited message"), WAIT); + expect(chips()).toBe(1); + expect(stageSpy).toHaveBeenCalledTimes(1); + const sharedIds = (view: { attachments: { id: string }[] }) => + view.attachments.map(({ id }) => id); + expect(sharedIds(getDraftStore().getView(scope))).toEqual(["file-unsent"]); + await getDraftStore().flush(scope); + expect(sharedIds(await app.env.services.draftService.get(scope))).toEqual(["file-unsent"]); + // Another window loads attachment payloads only for a draft it shows. + await secondWindow.ensurePayloads(scope); + expect(sharedIds(secondWindow.getView(scope))).toEqual(["file-unsent"]); + expect(secondWindow.getText(scope)).toBe("unsent draft"); + } finally { + await held?.settle(); + held?.spy.mockRestore(); + stageSpy.mockRestore(); + secondWindow.setClient(null); + await app.dispose(); + } + }, 120_000); }); From 557207f43b04cadd71b4afccaeb6e5c19111c66c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 21:52:51 +0000 Subject: [PATCH 04/15] tests: an edit keystroke never writes the persisted draft store (#5672) --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 299070c5b2..cddd26b25b 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -101,7 +101,16 @@ describe("Completing an edit of an older message", () => { let reloaded: DraftStore | null = null; try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - await startEditWithUnsentDraft(app, scope); + const textarea = await startEditWithUnsentDraft(app, scope); + // Typing in the edit writes only the memory buffer, never the persisted draft store. + const setText = jest.spyOn(getDraftStore(), "setText"); + const setAttachments = jest.spyOn(getDraftStore(), "setAttachments"); + typeIntoEdit(textarea, "edited before reload"); + await waitFor(() => expect(textarea.value).toBe("edited before reload")); + expect(setText).not.toHaveBeenCalled(); + expect(setAttachments).not.toHaveBeenCalled(); + setText.mockRestore(); + setAttachments.mockRestore(); await getDraftStore().flush(scope); expect((await app.env.services.draftService.get(scope)).text).toBe("unsent draft"); reloaded = await otherRenderer(app); From cb2ccf48d88e106124b8332d251007005e60e8dd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 21:56:41 +0000 Subject: [PATCH 05/15] tests: typing after the edited row is deleted reaches the unsent draft --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index cddd26b25b..c036c8544f 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -194,6 +194,33 @@ describe("Completing an edit of an older message", () => { } }, 120_000); + // The edit can end without the composer settling it: ChatPane drops the edit when its row + // leaves the transcript. Typing after that must reach the draft, not the stale edit buffer. + test("typing after the edited row is deleted goes to the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-row-deleted-typing" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + await startEditWithUnsentDraft(app, scope); + const cleared = await app.env.services.workspaceService.truncateHistory(app.workspaceId); + expect(cleared.success).toBe(true); + await waitFor(() => { + const edit = app.view.container.querySelector('textarea[aria-label="Edit message"]'); + expect(edit).toBeNull(); + }, LOAD_TOLERANT_WAIT); + await app.chat.expectInputValue("unsent draft", LOAD_TOLERANT_WAIT.timeout); + const composer = app.view.container.querySelector( + 'textarea[aria-label="Message"]' + ); + if (!composer) throw new Error("Message textarea not found"); + // Through the textarea, as a user types: the store shortcut would bypass the composer. + fireEvent.change(composer, { target: { value: "typed after the edit ended" } }); + await app.chat.expectInputValue("typed after the edit ended", LOAD_TOLERANT_WAIT.timeout); + expect(getDraftStore().getText(scope)).toBe("typed after the edit ended"); + } finally { + await app.dispose(); + } + }, 120_000); + test("keeps the unsent draft when the edit is a /compact command", async () => { const app = await createAppHarness({ branchPrefix: "edit-compact-keeps-draft" }); try { From 964d83692c446dd401b58a26c1a5c0cb7bc8a146 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 6 Oct 2026 22:13:21 +0000 Subject: [PATCH 06/15] fix: an edit that ends unsettled stays as a normal draft (row deleted, target gone) --- src/browser/features/ChatInput/index.tsx | 36 ++++++++++- tests/ui/chat/editKeepsUnsentDraft.test.ts | 71 ++++++++++++++++++---- 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 08f176148e..904c4b9d91 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -319,6 +319,8 @@ interface EditSession { preEditReviews: ReviewNoteDataForDisplay[] | null; /** Its draft was given back (cancel, or accepted send); it restores nothing again. */ settled: boolean; + /** An edit send for it has not returned yet: that send settles it, not releaseEndedEdit. */ + sendInFlight: boolean; } const ChatInputInner: React.FC = (props) => { @@ -1299,6 +1301,23 @@ const ChatInputInner: React.FC = (props) => { // By identity, not row id: a row reopened after a cancel is a new edit. const isOpenEditOrNone = (session: EditSession | null) => editingMessageIdRef.current === undefined || editSessionRef.current === session; + // ChatPane can end an edit without a composer handler running: its row was deleted or + // replaced, or a history-changed refresh found no target. The edit's text, attachments and + // notes then stay as a normal draft, after the unsent draft, so nothing typed is lost (on + // main they were the draft). An edit whose send is in flight is left to that send: an + // accepted edit replaces its row before the reply, and that is not a cancel. + const releaseEndedEdit = () => { + const session = editSessionRef.current; + if (!session || session.sendInFlight || editingMessageIdRef.current !== undefined) return; + restorePreEditDraftAfterSend(session); + }; + const markEditSendInFlight = (session: EditSession | null, inFlight: boolean) => { + if (session) session.sendInFlight = inFlight; + }; + // After every commit: the edit's end arrives as a prop change, and settled sessions no-op. + useEffect(() => { + releaseEndedEdit(); + }); // Method to restore text to input (used by compaction cancel) const restoreText = useCallback( @@ -1422,6 +1441,7 @@ const ChatInputInner: React.FC = (props) => { id: editingMessage.id, preEditReviews: draftReviews, settled: false, + sendInFlight: false, }; beginEditDraft(editingMessage.id, { text: editingMessage.pending.content, @@ -2199,8 +2219,8 @@ const ChatInputInner: React.FC = (props) => { return; case "target-not-found": if (!isSameEdit()) return; - // Leave edit mode without restoring the pre-edit draft: the typed text stays in - // the composer as a normal draft. + // Leave edit mode without cancelling: releaseEndedEdit keeps the typed text (and + // its notes) in the composer as a normal draft, after the unsent draft. onCancelEdit?.(); if (isMountedRef.current) { pushToast({ type: "error", message: EDIT_TARGET_GONE_MESSAGE }); @@ -2256,9 +2276,19 @@ const ChatInputInner: React.FC = (props) => { const onEditSendPendingChange = variant === "workspace" && editingMessageForUi ? props.onEditSendPendingChange : undefined; onEditSendPendingChange?.(true); + const editSession = + editingMessageForUi && editSessionRef.current?.id === editingMessageForUi.id + ? editSessionRef.current + : null; + markEditSendInFlight(editSession, true); await runWithFinally( () => sendComposerInput(overrides), - () => onEditSendPendingChange?.(false) + () => { + onEditSendPendingChange?.(false); + markEditSendInFlight(editSession, false); + // The edit can end while its send runs (a refusal whose refresh finds no target). + releaseEndedEdit(); + } ); }; const sendComposerInput = async (overrides?: InternalSendOverrides) => { diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index c036c8544f..38706f99ee 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -12,6 +12,7 @@ import { act, fireEvent, waitFor, within } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { DraftStore, getDraftStore } from "@/browser/stores/DraftStore"; +import { WorkspaceStore } from "@/browser/stores/WorkspaceStore"; import { createTestApiClient } from "@/browser/testUtils"; import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; @@ -73,6 +74,30 @@ async function expectUnsentDraftKept(app: AppHarness, scope: DraftScope) { expect(saved.attachments.map(({ id }) => id)).toEqual(["file-unsent"]); } +/** 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; +} + +/** An edit that ended unsettled: its text follows the unsent draft, which keeps its file. */ +async function expectEditKeptAsDraft(app: AppHarness, scope: DraftScope) { + await waitFor(() => { + const value = messageTextarea(app).value; + expect(value.startsWith("unsent draft")).toBe(true); + expect(value.trimEnd().endsWith("edited message")).toBe(true); + }, LOAD_TOLERANT_WAIT); + expect(getDraftStore().getText(scope)).toBe(messageTextarea(app).value); + expect( + getDraftStore() + .getView(scope) + .attachments.map(({ id }) => id) + ).toEqual(["file-unsent"]); +} + /** 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(); @@ -195,23 +220,21 @@ describe("Completing an edit of an older message", () => { }, 120_000); // The edit can end without the composer settling it: ChatPane drops the edit when its row - // leaves the transcript. Typing after that must reach the draft, not the stale edit buffer. - test("typing after the edited row is deleted goes to the unsent draft", async () => { + // leaves the transcript, or when a history-changed refresh finds no target. The edit's text + // and attachments then stay as a normal draft after the unsent draft, and typing goes there. + test("an edit whose row is deleted stays as a normal draft, after the unsent draft", async () => { const app = await createAppHarness({ branchPrefix: "edit-row-deleted-typing" }); try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - await startEditWithUnsentDraft(app, scope); + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); const cleared = await app.env.services.workspaceService.truncateHistory(app.workspaceId); expect(cleared.success).toBe(true); - await waitFor(() => { - const edit = app.view.container.querySelector('textarea[aria-label="Edit message"]'); - expect(edit).toBeNull(); - }, LOAD_TOLERANT_WAIT); - await app.chat.expectInputValue("unsent draft", LOAD_TOLERANT_WAIT.timeout); - const composer = app.view.container.querySelector( - 'textarea[aria-label="Message"]' - ); - if (!composer) throw new Error("Message textarea not found"); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectEditKeptAsDraft(app, scope); + + const composer = messageTextarea(app); // Through the textarea, as a user types: the store shortcut would bypass the composer. fireEvent.change(composer, { target: { value: "typed after the edit ended" } }); await app.chat.expectInputValue("typed after the edit ended", LOAD_TOLERANT_WAIT.timeout); @@ -221,6 +244,30 @@ describe("Completing an edit of an older message", () => { } }, 120_000); + test("an edit whose target a history-changed refresh cannot find stays as a normal draft", async () => { + const app = await createAppHarness({ branchPrefix: "edit-target-gone-keeps-text" }); + 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 expectEditKeptAsDraft(app, scope); + refreshSpy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + test("keeps the unsent draft when the edit is a /compact command", async () => { const app = await createAppHarness({ branchPrefix: "edit-compact-keeps-draft" }); try { From 00a1a18f7ef7a1505c0fbccf3d62805af9526686 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 7 Oct 2026 06:32:02 +0000 Subject: [PATCH 07/15] tests: an open edit keeps its typed text and files across a workspace switch (#5808) --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 95 +++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 38706f99ee..543890a8a0 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -12,7 +12,7 @@ import { act, fireEvent, waitFor, within } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { DraftStore, getDraftStore } from "@/browser/stores/DraftStore"; -import { WorkspaceStore } from "@/browser/stores/WorkspaceStore"; +import { WorkspaceStore, workspaceStore } from "@/browser/stores/WorkspaceStore"; import { createTestApiClient } from "@/browser/testUtils"; import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; @@ -20,6 +20,8 @@ import type { ReviewNoteData } from "@/common/types/review"; import { EDIT_HISTORY_CHANGED_MESSAGE } 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"; @@ -98,6 +100,23 @@ async function expectEditKeptAsDraft(app: AppHarness, scope: DraftScope) { ).toEqual(["file-unsent"]); } +/** 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); +} + /** 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(); @@ -148,6 +167,80 @@ describe("Completing an edit of an older message", () => { } }, 120_000); + // A workspace switch remounts the composer while ChatPane keeps the edit open: the edit's + // typed text and attachment changes must survive it, in memory only (#5808). An edit cannot + // add attachments, so its attachment change is removing one of the message's files. + test("an open edit keeps its typed text and attachments across a workspace switch", async () => { + const app = await createAppHarness({ branchPrefix: "edit-survives-switch" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const fileInput = 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(fileInput, { + target: { + files: [ + new File(["# kept"], "edit-kept.md", { type: "text/markdown" }), + new File(["# removed"], "edit-removed.md", { type: "text/markdown" }), + ], + }, + }); + await waitFor(() => { + expect(composerText(app)).toContain("edit-kept.md"); + expect(composerText(app)).toContain("edit-removed.md"); + }, LOAD_TOLERANT_WAIT); + const textarea = await startEditWithUnsentDraft(app, scope); + await waitFor(() => expect(composerText(app)).toContain("edit-removed.md"), LOAD_TOLERANT_WAIT); + typeIntoEdit(textarea, "edited before switch"); + await waitFor(() => expect(textarea.value).toBe("edited before switch")); + const removeButton = [ + ...app.view.container.querySelectorAll( + '[data-component="ChatInputSection"] button[aria-label="Remove attachment"]' + ), + ].find((button) => button.parentElement?.textContent?.includes("edit-removed.md")); + if (!removeButton) throw new Error("Remove button of edit-removed.md not found"); + fireEvent.click(removeButton); + await waitFor( + () => expect(composerText(app)).not.toContain("edit-removed.md"), + LOAD_TOLERANT_WAIT + ); + + const created = await app.env.orpc.workspace.create({ + projectPath: app.repoPath, + branchName: generateBranchName("edit-survives-switch-other"), + trunkBranch: await detectDefaultTrunkBranch(app.repoPath), + }); + if (!created.success) throw new Error(created.error); + workspaceStore.addWorkspace(created.metadata); + await showWorkspace(app, created.metadata.id, created.metadata.name); + await showWorkspace(app, app.workspaceId, app.metadata.name); + + await waitFor( + () => expect(editTextarea(app)?.value).toBe("edited before switch"), + LOAD_TOLERANT_WAIT + ); + expect(composerText(app)).toContain("edit-kept.md"); + expect(composerText(app)).not.toContain("edit-removed.md"); + // The unsent draft never took the edit's text or files, in memory or on the backend. + expect(getDraftStore().getText(scope)).toBe("unsent draft"); + expect( + getDraftStore() + .getView(scope) + .attachments.map(({ id }) => id) + ).toEqual(["file-unsent"]); + await getDraftStore().flush(scope); + 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 { + await app.dispose(); + } + }, 120_000); + test("an edit in one window does not reach another window's composer (#5571)", async () => { const app = await createAppHarness({ branchPrefix: "edit-other-window" }); const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; From d0f6497bb704efdcaef0393c3cfe6f2cd6aabd3d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 7 Oct 2026 06:52:01 +0000 Subject: [PATCH 08/15] fix: an open edit survives a workspace switch, in memory only (#5808) --- src/browser/components/ChatPane/ChatPane.tsx | 70 ++++++++++++++----- src/browser/features/ChatInput/index.tsx | 70 +++++++++++++++---- .../features/ChatInput/useComposerDraft.ts | 66 +++++++++++++---- tests/ui/chat/editKeepsUnsentDraft.test.ts | 33 ++++++++- 4 files changed, 195 insertions(+), 44 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index ec0067eb19..775d28c389 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -6,6 +6,7 @@ import React, { useRef, useDeferredValue, useMemo, + useSyncExternalStore, } from "react"; import { Lightbulb } from "lucide-react"; import { Skeleton } from "@/browser/components/Skeleton/Skeleton"; @@ -311,6 +312,44 @@ export const ChatPane: React.FC = (props) => { ); }; +// The open edit per workspace, in module memory: a workspace switch keeps it, so switching back +// shows the edit with its typed changes, whose buffer the composer keeps (#5808). Not React +// state: the shell unmounts this pane while a workspace loads. Memory only, so a reload drops +// it. An entry goes when its edit ends; one left by a workspace removed mid-edit is small and +// stays until reload. +let editTargets: Readonly> = {}; +const editTargetListeners = new Set<() => void>(); +const readEditTargets = () => editTargets; +function subscribeEditTargets(listener: () => void) { + editTargetListeners.add(listener); + return () => { + editTargetListeners.delete(listener); + }; +} +function setEditingByWorkspace( + update: ( + previous: Readonly> + ) => Readonly> +) { + const next = update(editTargets); + if (next === editTargets) return; + editTargets = next; + for (const listener of editTargetListeners) listener(); +} + +/** `edits` with this workspace's edit set or removed; the same object when nothing changes. */ +function withWorkspaceEdit( + edits: Readonly>, + workspaceId: string, + edit: EditingMessageState | undefined +): Readonly> { + if (edits[workspaceId] === edit) return edits; + if (edit) return { ...edits, [workspaceId]: edit }; + if (!(workspaceId in edits)) return edits; + const { [workspaceId]: _removed, ...rest } = edits; + return rest; +} + const ChatPaneContent: React.FC = (props) => { const { workspaceId, @@ -378,30 +417,24 @@ const ChatPaneContent: React.FC = (props) => { ? queuedActionErrorState.error : null; - const [editingState, setEditingState] = useState(() => ({ - workspaceId, - message: undefined as EditingMessageState | undefined, - })); - const editingMessage = - editingState.workspaceId === workspaceId ? editingState.message : undefined; + const editingByWorkspace = useSyncExternalStore(subscribeEditTargets, readEditTargets); + const editingMessage = editingByWorkspace[workspaceId]; const setEditingMessage = useCallback( (message: EditingMessageState | undefined) => { // Any change of edit target ends the conflict recovery of the previous edit (no-op // when none is pending); only the composer's own updater keeps a request alive. storeRaw.cancelTranscriptRefresh(workspaceId); - setEditingState({ - workspaceId, - message: transcriptOnly ? undefined : message, - }); + setEditingByWorkspace((previous) => + withWorkspaceEdit(previous, workspaceId, transcriptOnly ? undefined : message) + ); }, [storeRaw, workspaceId, transcriptOnly] ); const updateEditingMessage = (update: (current: EditingMessageState) => EditingMessageState) => { - setEditingState((previous) => - previous.workspaceId === workspaceId && previous.message - ? { ...previous, message: update(previous.message) } - : previous - ); + setEditingByWorkspace((previous) => { + const current = previous[workspaceId]; + return current ? withWorkspaceEdit(previous, workspaceId, update(current)) : previous; + }); }; // The workspace with an unresolved edit send: no edit starts there meanwhile (#5226). const [editSendPendingIn, setEditSendPendingIn] = useState(null); @@ -434,7 +467,7 @@ const ChatPaneContent: React.FC = (props) => { // stale edit state instead of leaving the transcript stuck at an edit cutoff. useEffect(() => { if (transcriptOnly && editingMessage) { - setEditingState({ workspaceId, message: undefined }); + setEditingByWorkspace((previous) => withWorkspaceEdit(previous, workspaceId, undefined)); } }, [editingMessage, transcriptOnly, workspaceId]); @@ -890,8 +923,8 @@ const ChatPaneContent: React.FC = (props) => { clearBackgroundBashError(); }, [clearBackgroundBashError, workspaceId]); + // A switch keeps each workspace's open edit (editingByWorkspace). useEffect(() => { - setEditingState({ workspaceId, message: undefined }); setPendingTimelineReveal(null); }, [workspaceId]); @@ -1354,6 +1387,9 @@ const ChatPaneContent: React.FC = (props) => { // Must be before early return to satisfy React Hooks rules useEffect(() => { if (!workspaceState || !editingMessage) return; + // Back from another workspace, a kept edit's rows replay first: only a caught-up + // transcript can show that its row is gone (#5808). + if (!workspaceState.isTranscriptCaughtUp) return; // Conflict recovery re-reads the transcript (a full replay empties the aggregator first, // a pre-window range discards cached pages); the refresh outcome decides whether the // edited row is gone, not the transient absence of its row. diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 904c4b9d91..3929f839d6 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -321,6 +321,24 @@ interface EditSession { settled: boolean; /** An edit send for it has not returned yet: that send settles it, not releaseEndedEdit. */ sendInFlight: boolean; + /** The edit's notes when its composer unmounted (a workspace switch), restored on return. */ + editReviews: ReviewNoteDataForDisplay[] | null; +} + +// The open edit's session per workspace, next to its buffer (useComposerDraft): both outlive +// the composer, which a workspace switch remounts while ChatPane keeps the edit open (#5808). +// Memory only, like the buffer. An entry goes when its edit settles; one left by a workspace +// removed mid-edit is small and stays until reload. +const editSessions = new Map(); +function keepEditReviews(session: EditSession | null, reviews: ReviewNoteDataForDisplay[] | null) { + if (session && !session.settled) session.editReviews = reviews; +} +/** Settle an edit session: it restores nothing again, and its workspace forgets it. */ +function settleEditSession(session: EditSession) { + session.settled = true; + for (const [workspaceId, kept] of editSessions) { + if (kept === session) editSessions.delete(workspaceId); + } } const ChatInputInner: React.FC = (props) => { @@ -515,7 +533,7 @@ const ChatInputInner: React.FC = (props) => { pushToast, }); const { input, setInput, attachments, setAttachments, draftReviews, setDraftReviews } = draft; - const { getDraft, setDraft, getLiveText, beginEditDraft, endEditDraft } = draft; + const { getDraft, setDraft, getLiveText, beginEditDraft, endEditDraft, updateEditDraft } = draft; const { reviewOverrideActive, reviewData, reviewIdsForCheck, reviewPanelItems } = draft; const { removeDraftReview, updateDraftReviewNote, draftScope, latestInputValueRef } = draft; const { @@ -1250,7 +1268,9 @@ 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); + const editSessionRef = useRef( + workspaceId ? (editSessions.get(workspaceId) ?? null) : null + ); // Live review override for completions that settle after the render they started in. const draftReviewsRef = useRef(draftReviews); useLayoutEffect(() => { @@ -1259,7 +1279,7 @@ const ChatInputInner: React.FC = (props) => { const restorePreEditDraft = () => { const session = editSessionRef.current; if (!session || session.settled || session.id !== editingMessageIdRef.current) return; - session.settled = true; + settleEditSession(session); // The edit text is dropped; the composer shows the unsent draft again. endEditDraft(); setDraftReviews(session.preEditReviews); @@ -1276,7 +1296,7 @@ const ChatInputInner: React.FC = (props) => { dropEditReviews = false ): boolean => { if (!session || session.settled) return false; - session.settled = true; + settleEditSession(session); const { preEditReviews } = session; if (dropEditReviews) setDraftReviews(null); // The composer goes back to the unsent draft; what was typed in the edit buffer while the @@ -1314,6 +1334,13 @@ const ChatInputInner: React.FC = (props) => { const markEditSendInFlight = (session: EditSession | null, inFlight: boolean) => { if (session) session.sendInFlight = inFlight; }; + // A workspace switch unmounts this composer while the edit stays open: keep the edit's notes + // in its session, as its text and files are kept in the buffer (#5808). + useEffect(() => { + const sessionRef = editSessionRef; + const reviewsRef = draftReviewsRef; + return () => keepEditReviews(sessionRef.current, reviewsRef.current); + }, []); // After every commit: the edit's end arrives as a prop change, and settled sessions no-op. useEffect(() => { releaseEndedEdit(); @@ -1437,17 +1464,25 @@ const ChatInputInner: React.FC = (props) => { } if (appliedEditIdRef.current === editingMessage.id) return; appliedEditIdRef.current = editingMessage.id; - editSessionRef.current = { - id: editingMessage.id, - preEditReviews: draftReviews, - settled: false, - sendInFlight: false, - }; + const kept = editSessionRef.current; + // Back from another workspace, the edit's session and buffer outlived the composer: keep + // them (and the pre-edit notes) instead of starting the edit over. + if (!kept || kept.id !== editingMessage.id || kept.settled) { + const session: EditSession = { + id: editingMessage.id, + preEditReviews: draftReviews, + settled: false, + sendInFlight: false, + editReviews: null, + }; + editSessionRef.current = session; + if (workspaceId) editSessions.set(workspaceId, session); + } beginEditDraft(editingMessage.id, { text: editingMessage.pending.content, attachments: pendingChatAttachments(editingMessage.pending, `edit-${editingMessage.id}`), }); - setDraftReviews(editingMessage.pending.reviews); + setDraftReviews(editSessionRef.current?.editReviews ?? editingMessage.pending.reviews); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -1457,7 +1492,7 @@ const ChatInputInner: React.FC = (props) => { inputRef.current.focus(); } }, 0); - }, [editingMessage, draftReviews, beginEditDraft, setDraftReviews]); + }, [editingMessage, draftReviews, beginEditDraft, setDraftReviews, workspaceId]); // Project live workflow run cards for foreground slash invocations after reloads. useEffect(() => { @@ -2082,7 +2117,10 @@ const ChatInputInner: React.FC = (props) => { for (const action of actions) { switch (action.type) { case "clear-input": - if (!editCancelled()) setInput(""); + // An editing command clears its edit's buffer only: its row can already be gone. + if (commandEditSession) { + if (!commandEditSession.settled) updateEditDraft(commandEditSession.id, { text: "" }); + } else if (!editCancelled()) setInput(""); break; case "reset-input-height": if (inputRef.current) inputRef.current.style.height = ""; @@ -2100,7 +2138,11 @@ const ChatInputInner: React.FC = (props) => { setSendingCount((count) => count + (action.sending ? 1 : -1)); break; case "clear-attachments": - if (!editCancelled()) setAttachments([]); + if (commandEditSession) { + if (!commandEditSession.settled) { + updateEditDraft(commandEditSession.id, { attachments: [] }); + } + } else if (!editCancelled()) setAttachments([]); break; case "detach-reviews": if (variant === "workspace") props.onDetachAllReviews?.(); diff --git a/src/browser/features/ChatInput/useComposerDraft.ts b/src/browser/features/ChatInput/useComposerDraft.ts index bec16c088d..9a21ed8e75 100644 --- a/src/browser/features/ChatInput/useComposerDraft.ts +++ b/src/browser/features/ChatInput/useComposerDraft.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { defaultCreationDraftScope, getDraftStore, @@ -7,6 +7,7 @@ import { } from "@/browser/stores/DraftStore"; import type { ReviewNoteDataForDisplay } from "@/common/types/message"; import type { Review } from "@/common/types/review"; +import assert from "@/common/utils/assert"; import { DRAFT_ID_PATTERN } from "@/constants/drafts"; import type { ChatAttachment } from "./ChatAttachments"; import type { Toast } from "./ChatInputToast"; @@ -52,6 +53,27 @@ interface EditDraft { attachments: ChatAttachment[]; } +// The open edit's buffer per workspace, in module memory. A workspace switch remounts the +// composer (ChatPane keys it by workspace) while ChatPane keeps the edit open, so the buffer +// must outlive the composer (#5808). Module memory, not the draft store: a reload still drops +// the edit, and another window never sees it (#5672, #5571). +const editDrafts = new Map(); +const editDraftListeners = new Map void>>(); +function writeStoredEditDraft(key: string, next: EditDraft | null) { + if (next) editDrafts.set(key, next); + else editDrafts.delete(key); + for (const listener of editDraftListeners.get(key) ?? []) listener(); +} +function subscribeEditDraft(key: string, listener: () => void) { + const listeners = editDraftListeners.get(key) ?? new Set<() => void>(); + editDraftListeners.set(key, listeners); + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) editDraftListeners.delete(key); + }; +} + type Update = T | ((previous: T) => T); const applyUpdate = (value: Update, previous: T): T => typeof value === "function" ? (value as (previous: T) => T)(previous) : value; @@ -66,21 +88,28 @@ export function useComposerDraft(options: UseComposerDraftOptions) { // While a message is edited, the composer edits this buffer instead of the draft: the edit // text stays in this window's memory, so a reload keeps the unsent draft (#5672) and another // window never shows the edit (#5571). A reload drops the edit; that is the chosen tradeoff. - // The ref is the live copy for writes that run after an await; renders read the state. - const [editDraft, setEditDraftState] = useState(null); - const editDraftRef = useRef(null); + // Edits exist only in a workspace composer, so the workspace keys the buffer (editDrafts). + const editKey = options.variant === "workspace" ? options.workspaceId : null; + const readEditDraft = () => (editKey ? (editDrafts.get(editKey) ?? null) : null); + const editDraft = useSyncExternalStore( + (listener) => (editKey ? subscribeEditDraft(editKey, listener) : () => undefined), + readEditDraft + ); const editIdRef = useRef(options.editMessageId); useLayoutEffect(() => { editIdRef.current = options.editMessageId; }); const writeEditDraft = (next: EditDraft | null) => { - editDraftRef.current = next; - setEditDraftState(next); + if (!editKey) { + assert(next === null, "An edit buffer needs a workspace composer"); + return; + } + writeStoredEditDraft(editKey, next); }; // Only the open edit's buffer counts. One left behind by an edit that ended without settling - // (its row was replaced, a workspace switch) is ignored and discarded like a cancelled edit. + // (its row was replaced) is ignored until the composer releases it. const liveEditDraft = () => { - const current = editDraftRef.current; + const current = readEditDraft(); return current !== null && current.editId === editIdRef.current ? current : null; }; const editActive = editDraft !== null && editDraft.editId === options.editMessageId; @@ -185,12 +214,25 @@ export function useComposerDraft(options: UseComposerDraftOptions) { setInput, /** The live composer text (the open edit's, else the draft's), for code after an await. */ getLiveText: () => liveEditDraft()?.text ?? draftStore.getText(draftScope), - /** Fill the edit buffer; from now on the composer edits it, not the draft. */ - beginEditDraft: (editId: string, next: { text: string; attachments: ChatAttachment[] }) => - writeEditDraft({ editId, ...next }), + /** + * Fill the edit buffer; from now on the composer edits it, not the draft. A buffer this edit + * already has (it outlived a workspace switch) is kept with its typed changes. + */ + beginEditDraft: (editId: string, next: { text: string; attachments: ChatAttachment[] }) => { + if (readEditDraft()?.editId === editId) return; + writeEditDraft({ editId, ...next }); + }, + /** + * Change this edit's buffer, whether or not ChatPane still shows the edit (an accepted edit + * command replaces its row before it clears the composer). Never the shared draft. + */ + updateEditDraft: (editId: string, patch: Partial>) => { + const edit = readEditDraft(); + if (edit?.editId === editId) writeEditDraft({ ...edit, ...patch }); + }, /** Drop the edit buffer and return what it held (text typed during an edit send). */ endEditDraft: () => { - const edit = editDraftRef.current; + const edit = readEditDraft(); writeEditDraft(null); return edit; }, diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 543890a8a0..2c9dc55685 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -194,7 +194,10 @@ describe("Completing an edit of an older message", () => { expect(composerText(app)).toContain("edit-removed.md"); }, LOAD_TOLERANT_WAIT); const textarea = await startEditWithUnsentDraft(app, scope); - await waitFor(() => expect(composerText(app)).toContain("edit-removed.md"), LOAD_TOLERANT_WAIT); + await waitFor( + () => expect(composerText(app)).toContain("edit-removed.md"), + LOAD_TOLERANT_WAIT + ); typeIntoEdit(textarea, "edited before switch"); await waitFor(() => expect(textarea.value).toBe("edited before switch")); const removeButton = [ @@ -241,6 +244,34 @@ describe("Completing an edit of an older message", () => { } }, 120_000); + // A kept edit whose row goes while another workspace is shown ends on return, like any edit + // whose row is deleted: its text stays as a normal draft after the unsent draft. + test("a kept edit whose row is deleted while another workspace is shown ends on return", async () => { + const app = await createAppHarness({ branchPrefix: "edit-switch-row-deleted" }); + 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 created = await app.env.orpc.workspace.create({ + projectPath: app.repoPath, + branchName: generateBranchName("edit-switch-row-deleted-other"), + trunkBranch: await detectDefaultTrunkBranch(app.repoPath), + }); + if (!created.success) throw new Error(created.error); + workspaceStore.addWorkspace(created.metadata); + await showWorkspace(app, created.metadata.id, created.metadata.name); + + const cleared = await app.env.services.workspaceService.truncateHistory(app.workspaceId); + expect(cleared.success).toBe(true); + await showWorkspace(app, app.workspaceId, app.metadata.name); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectEditKeptAsDraft(app, scope); + } finally { + await app.dispose(); + } + }, 120_000); + test("an edit in one window does not reach another window's composer (#5571)", async () => { const app = await createAppHarness({ branchPrefix: "edit-other-window" }); const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; From 4cdc7dea65922a4b01654eaea68dd8e682b774f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 7 Oct 2026 07:05:01 +0000 Subject: [PATCH 09/15] fix: a kept edit whose row is only outside the replayed window stays open --- src/browser/components/ChatPane/ChatPane.tsx | 3 + tests/ui/chat/editKeepsUnsentDraft.test.ts | 74 +++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 775d28c389..56a5b94b80 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1410,6 +1410,9 @@ const ChatPaneContent: React.FC = (props) => { )?.historyId; if (!editCutoffHistoryId) { + // A windowed replay can leave a kept edit's row in older history, unloaded: that is not + // a deletion, so the edit stays (#5808). Its send still checks the rows (precondition). + if (workspaceState.hasOlderHistory) return; // Message was replaced or deleted - clear editing state setEditingMessage(undefined); } diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 2c9dc55685..bdbf67f86c 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -12,7 +12,12 @@ import { act, fireEvent, waitFor, within } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { DraftStore, getDraftStore } from "@/browser/stores/DraftStore"; -import { WorkspaceStore, workspaceStore } from "@/browser/stores/WorkspaceStore"; +import { + WorkspaceStore, + useWorkspaceStoreRaw, + workspaceStore, + type WorkspaceState, +} from "@/browser/stores/WorkspaceStore"; import { createTestApiClient } from "@/browser/testUtils"; import { getAutoCompactionThresholdKey } from "@/common/constants/storage"; import type { DraftScope } from "@/common/orpc/schemas/drafts"; @@ -272,6 +277,73 @@ describe("Completing an edit of an older message", () => { } }, 120_000); + // Back from another workspace, a windowed replay (#4961) can load only the newest rows: the + // kept edit's row is then older history, not gone, and the edit stays open (#5808). The store + // view below plays that replay: caught up, the row outside the window, older history left. + test("a kept edit whose row is outside the replayed window stays open on return", async () => { + const app = await createAppHarness({ branchPrefix: "edit-switch-row-windowed" }); + // eslint-disable-next-line @typescript-eslint/unbound-method -- called below with the store as `this` + const realGetState = WorkspaceStore.prototype.getWorkspaceState; + const windowed = new WeakMap(); + let stateSpy: jest.SpyInstance | null = null; + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited before switch"); + await waitFor(() => expect(textarea.value).toBe("edited before switch")); + const editedRow = useWorkspaceStoreRaw() + .getWorkspaceState(app.workspaceId) + .messages.find((row) => row.type === "user" && row.content === "first message"); + const editedRowId = editedRow?.type === "user" ? editedRow.historyId : undefined; + expect(editedRowId).toBeDefined(); + const created = await app.env.orpc.workspace.create({ + projectPath: app.repoPath, + branchName: generateBranchName("edit-switch-row-windowed-other"), + trunkBranch: await detectDefaultTrunkBranch(app.repoPath), + }); + if (!created.success) throw new Error(created.error); + workspaceStore.addWorkspace(created.metadata); + await showWorkspace(app, created.metadata.id, created.metadata.name); + + stateSpy = jest + .spyOn(WorkspaceStore.prototype, "getWorkspaceState") + .mockImplementation(function (this: WorkspaceStore, workspaceId: string) { + const state = realGetState.call(this, workspaceId); + if (workspaceId !== app.workspaceId) return state; + // One view object per store state: useSyncExternalStore needs a stable snapshot. + let view = windowed.get(state); + if (!view) { + view = { + ...state, + hasOlderHistory: true, + messages: state.messages.filter( + (row) => !("historyId" in row) || row.historyId !== editedRowId + ), + }; + windowed.set(state, view); + } + return view; + }); + await showWorkspace(app, app.workspaceId, app.metadata.name); + await waitFor( + () => + expect( + useWorkspaceStoreRaw().getWorkspaceState(app.workspaceId).isTranscriptCaughtUp + ).toBe(true), + LOAD_TOLERANT_WAIT + ); + // Let the caught-up render run ChatPane's row check; the edit stays. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + expect(editTextarea(app)?.value).toBe("edited before switch"); + expect(getDraftStore().getText(scope)).toBe("unsent draft"); + } finally { + stateSpy?.mockRestore(); + await app.dispose(); + } + }, 120_000); + test("an edit in one window does not reach another window's composer (#5571)", async () => { const app = await createAppHarness({ branchPrefix: "edit-other-window" }); const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; From 7821138546743fbbf87b1693320d4197bab81110 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 7 Oct 2026 07:48:05 +0000 Subject: [PATCH 10/15] tests: an edit that loses its target keeps its contents in the draft (transcript-only, second Edit) --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 86 ++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index bdbf67f86c..ddbb5ac232 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -122,6 +122,40 @@ async function showWorkspace(app: AppHarness, workspaceId: string, name: string) }, LOAD_TOLERANT_WAIT); } +/** 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); +} + +/** + * An edit that lost its target without a settle: its text follows the unsent draft, and its + * file joins the unsent draft's file, in memory and on the backend. + */ +async function expectEditContentsInDraft(app: AppHarness, scope: DraftScope, filename: string) { + await waitFor(() => { + const text = getDraftStore().getText(scope); + expect(text.startsWith("unsent draft")).toBe(true); + expect(text.trimEnd().endsWith("edited message")).toBe(true); + }, LOAD_TOLERANT_WAIT); + const names = (attachments: { filename?: string; id: string }[]) => + attachments.map((attachment) => attachment.filename ?? attachment.id); + expect(names(getDraftStore().getView(scope).attachments)).toEqual(["unsent.txt", filename]); + await getDraftStore().flush(scope); + const saved = await app.env.services.draftService.get(scope); + expect(saved.text).toBe(getDraftStore().getText(scope)); + expect(saved.attachments).toHaveLength(2); +} + /** 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(); @@ -344,6 +378,58 @@ describe("Completing an edit of an older message", () => { } }, 120_000); + // An unsettled edit that loses its target keeps its contents in the workspace's draft, after + // the unsent draft (#5801 review): here the workspace turns transcript-only mid-edit, and the + // composer is replaced by the read-only notice. + test("an edit keeps its text and files in the 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 expectEditContentsInDraft(app, scope, "edit-file.md"); + } finally { + await app.dispose(); + } + }, 120_000); + + // Same rule when a second Edit replaces the open edit's target: the first edit's contents + // join the draft, and the second edit starts from its own message. + test("a second Edit keeps the first edit's text and files in the 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 expectEditContentsInDraft(app, scope, "edit-file.md"); + // Cancelling the second edit shows the draft with the first edit's contents. + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + expect(messageTextarea(app).value).toBe(getDraftStore().getText(scope)); + expect(composerText(app)).toContain("edit-file.md"); + } finally { + await app.dispose(); + } + }, 120_000); + test("an edit in one window does not reach another window's composer (#5571)", async () => { const app = await createAppHarness({ branchPrefix: "edit-other-window" }); const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; From e8acc69cd8f70164bd464749b5b612544306ab39 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 7 Oct 2026 07:53:56 +0000 Subject: [PATCH 11/15] fix: an edit that loses its target keeps its text and files in the draft, after the unsent draft --- src/browser/components/ChatPane/ChatPane.tsx | 10 +++++++-- .../features/ChatInput/useComposerDraft.ts | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 56a5b94b80..9c8fcc5541 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -42,6 +42,7 @@ import { import { getRetryBarrierDerivation } from "./retryBarrierDerivation"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { ChatInput, type ChatInputAPI } from "@/browser/features/ChatInput/index"; +import { keepUnsettledEditInDraft } from "@/browser/features/ChatInput/useComposerDraft"; import type { QueueDispatchMode } from "@/browser/features/ChatInput/types"; import { mergeConsecutiveStreamErrors, @@ -331,10 +332,15 @@ function setEditingByWorkspace( previous: Readonly> ) => Readonly> ) { - const next = update(editTargets); - if (next === editTargets) return; + const previous = editTargets; + const next = update(previous); + if (next === previous) return; editTargets = next; for (const listener of editTargetListeners) listener(); + // Every cleared or replaced target goes through here, so its unsettled contents are kept. + for (const [workspaceId, edit] of Object.entries(previous)) { + if (next[workspaceId]?.id !== edit.id) keepUnsettledEditInDraft(workspaceId, edit.id); + } } /** `edits` with this workspace's edit set or removed; the same object when nothing changes. */ diff --git a/src/browser/features/ChatInput/useComposerDraft.ts b/src/browser/features/ChatInput/useComposerDraft.ts index 9a21ed8e75..dceda66e3d 100644 --- a/src/browser/features/ChatInput/useComposerDraft.ts +++ b/src/browser/features/ChatInput/useComposerDraft.ts @@ -9,6 +9,7 @@ import type { ReviewNoteDataForDisplay } from "@/common/types/message"; import type { Review } from "@/common/types/review"; import assert from "@/common/utils/assert"; import { DRAFT_ID_PATTERN } from "@/constants/drafts"; +import { joinDraftText } from "@/common/utils/composerDraftText"; import type { ChatAttachment } from "./ChatAttachments"; import type { Toast } from "./ChatInputToast"; @@ -74,6 +75,26 @@ function subscribeEditDraft(key: string, listener: () => void) { }; } +/** + * The one rule for an edit that loses its target without a settle (cancel and accepted sends + * settle first and leave no buffer): ChatPane calls this whenever a workspace's edit target is + * cleared or replaced (transcript-only, a second Edit, a deleted row, a refresh that finds no + * target). The edit's text and files stay in the workspace's normal draft, after the unsent + * draft, never over it. Taking the buffer first makes it exactly once. + */ +export function keepUnsettledEditInDraft(workspaceId: string, editId: string) { + const edit = editDrafts.get(workspaceId); + if (edit?.editId !== editId) return; + writeStoredEditDraft(workspaceId, null); + const scope: DraftStoreScope = { kind: "workspace", workspaceId }; + if (edit.text.trim().length > 0) { + getDraftStore().setText(scope, (current) => joinDraftText(current, edit.text)); + } + if (edit.attachments.length > 0) { + getDraftStore().setAttachments(scope, (current) => [...current, ...edit.attachments]); + } +} + type Update = T | ((previous: T) => T); const applyUpdate = (value: Update, previous: T): T => typeof value === "function" ? (value as (previous: T) => T)(previous) : value; From a3216f8b9133f816ec0caf555e4544cc7366f04f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 06:30:06 +0000 Subject: [PATCH 12/15] tests: a workspace switch ends an open edit and keeps its contents in the draft (#5808) --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 944 +++++++++++++++++---- tests/ui/harness/createAppHarness.ts | 4 +- tests/ui/renderReviewPanel.tsx | 6 +- 3 files changed, 775 insertions(+), 179 deletions(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index ddbb5ac232..429e5358b7 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -25,17 +25,32 @@ import type { ReviewNoteData } from "@/common/types/review"; import { EDIT_HISTORY_CHANGED_MESSAGE } from "@/constants/transcriptBarrier"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { Err } from "@/common/types/result"; +import { joinDraftText } from "@/common/utils/composerDraftText"; 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 }; -async function startEditWithUnsentDraft(app: AppHarness, scope: DraftScope) { +/** + * The mock's reply to `text`. It echoes the sent text, with its one note formatted in front, + * so a reply to a message with a note is matched within that one reply. + */ +const mockReply = (text: string, withNote: boolean) => + withNote + ? new RegExp(`Mock response: [^<]*\\s*${text}`) + : `Mock response: ${text}`; + +/** `rowNote`: a review note sent with the edited row, so the edit opens with it. */ +async function startEditWithUnsentDraft(app: AppHarness, scope: DraftScope, rowNote?: string) { + if (rowNote) { + await attachStoreReview(app, "review-row", rowNote); + await waitFor(() => expect(composerText(app)).toContain(rowNote), LOAD_TOLERANT_WAIT); + } await app.chat.send("first message"); await app.chat.expectTranscriptContains( - "Mock response: first message", + mockReply("first message", rowNote !== undefined), LOAD_TOLERANT_WAIT.timeout ); await app.chat.expectStreamComplete(); @@ -188,8 +203,11 @@ describe("Completing an edit of an older message", () => { // Typing in the edit writes only the memory buffer, never the persisted draft store. const setText = jest.spyOn(getDraftStore(), "setText"); const setAttachments = jest.spyOn(getDraftStore(), "setAttachments"); - typeIntoEdit(textarea, "edited before reload"); - await waitFor(() => expect(textarea.value).toBe("edited before reload")); + // Several keystrokes, as a user types: none of them reaches the draft store. + for (const typed of ["e", "ed", "edi", "edit", "edited before reload"]) { + typeIntoEdit(textarea, typed); + await waitFor(() => expect(textarea.value).toBe(typed)); + } expect(setText).not.toHaveBeenCalled(); expect(setAttachments).not.toHaveBeenCalled(); setText.mockRestore(); @@ -206,178 +224,6 @@ describe("Completing an edit of an older message", () => { } }, 120_000); - // A workspace switch remounts the composer while ChatPane keeps the edit open: the edit's - // typed text and attachment changes must survive it, in memory only (#5808). An edit cannot - // add attachments, so its attachment change is removing one of the message's files. - test("an open edit keeps its typed text and attachments across a workspace switch", async () => { - const app = await createAppHarness({ branchPrefix: "edit-survives-switch" }); - try { - const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - const fileInput = 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(fileInput, { - target: { - files: [ - new File(["# kept"], "edit-kept.md", { type: "text/markdown" }), - new File(["# removed"], "edit-removed.md", { type: "text/markdown" }), - ], - }, - }); - await waitFor(() => { - expect(composerText(app)).toContain("edit-kept.md"); - expect(composerText(app)).toContain("edit-removed.md"); - }, LOAD_TOLERANT_WAIT); - const textarea = await startEditWithUnsentDraft(app, scope); - await waitFor( - () => expect(composerText(app)).toContain("edit-removed.md"), - LOAD_TOLERANT_WAIT - ); - typeIntoEdit(textarea, "edited before switch"); - await waitFor(() => expect(textarea.value).toBe("edited before switch")); - const removeButton = [ - ...app.view.container.querySelectorAll( - '[data-component="ChatInputSection"] button[aria-label="Remove attachment"]' - ), - ].find((button) => button.parentElement?.textContent?.includes("edit-removed.md")); - if (!removeButton) throw new Error("Remove button of edit-removed.md not found"); - fireEvent.click(removeButton); - await waitFor( - () => expect(composerText(app)).not.toContain("edit-removed.md"), - LOAD_TOLERANT_WAIT - ); - - const created = await app.env.orpc.workspace.create({ - projectPath: app.repoPath, - branchName: generateBranchName("edit-survives-switch-other"), - trunkBranch: await detectDefaultTrunkBranch(app.repoPath), - }); - if (!created.success) throw new Error(created.error); - workspaceStore.addWorkspace(created.metadata); - await showWorkspace(app, created.metadata.id, created.metadata.name); - await showWorkspace(app, app.workspaceId, app.metadata.name); - - await waitFor( - () => expect(editTextarea(app)?.value).toBe("edited before switch"), - LOAD_TOLERANT_WAIT - ); - expect(composerText(app)).toContain("edit-kept.md"); - expect(composerText(app)).not.toContain("edit-removed.md"); - // The unsent draft never took the edit's text or files, in memory or on the backend. - expect(getDraftStore().getText(scope)).toBe("unsent draft"); - expect( - getDraftStore() - .getView(scope) - .attachments.map(({ id }) => id) - ).toEqual(["file-unsent"]); - await getDraftStore().flush(scope); - 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 { - await app.dispose(); - } - }, 120_000); - - // A kept edit whose row goes while another workspace is shown ends on return, like any edit - // whose row is deleted: its text stays as a normal draft after the unsent draft. - test("a kept edit whose row is deleted while another workspace is shown ends on return", async () => { - const app = await createAppHarness({ branchPrefix: "edit-switch-row-deleted" }); - 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 created = await app.env.orpc.workspace.create({ - projectPath: app.repoPath, - branchName: generateBranchName("edit-switch-row-deleted-other"), - trunkBranch: await detectDefaultTrunkBranch(app.repoPath), - }); - if (!created.success) throw new Error(created.error); - workspaceStore.addWorkspace(created.metadata); - await showWorkspace(app, created.metadata.id, created.metadata.name); - - const cleared = await app.env.services.workspaceService.truncateHistory(app.workspaceId); - expect(cleared.success).toBe(true); - await showWorkspace(app, app.workspaceId, app.metadata.name); - await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); - await expectEditKeptAsDraft(app, scope); - } finally { - await app.dispose(); - } - }, 120_000); - - // Back from another workspace, a windowed replay (#4961) can load only the newest rows: the - // kept edit's row is then older history, not gone, and the edit stays open (#5808). The store - // view below plays that replay: caught up, the row outside the window, older history left. - test("a kept edit whose row is outside the replayed window stays open on return", async () => { - const app = await createAppHarness({ branchPrefix: "edit-switch-row-windowed" }); - // eslint-disable-next-line @typescript-eslint/unbound-method -- called below with the store as `this` - const realGetState = WorkspaceStore.prototype.getWorkspaceState; - const windowed = new WeakMap(); - let stateSpy: jest.SpyInstance | null = null; - try { - const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; - const textarea = await startEditWithUnsentDraft(app, scope); - typeIntoEdit(textarea, "edited before switch"); - await waitFor(() => expect(textarea.value).toBe("edited before switch")); - const editedRow = useWorkspaceStoreRaw() - .getWorkspaceState(app.workspaceId) - .messages.find((row) => row.type === "user" && row.content === "first message"); - const editedRowId = editedRow?.type === "user" ? editedRow.historyId : undefined; - expect(editedRowId).toBeDefined(); - const created = await app.env.orpc.workspace.create({ - projectPath: app.repoPath, - branchName: generateBranchName("edit-switch-row-windowed-other"), - trunkBranch: await detectDefaultTrunkBranch(app.repoPath), - }); - if (!created.success) throw new Error(created.error); - workspaceStore.addWorkspace(created.metadata); - await showWorkspace(app, created.metadata.id, created.metadata.name); - - stateSpy = jest - .spyOn(WorkspaceStore.prototype, "getWorkspaceState") - .mockImplementation(function (this: WorkspaceStore, workspaceId: string) { - const state = realGetState.call(this, workspaceId); - if (workspaceId !== app.workspaceId) return state; - // One view object per store state: useSyncExternalStore needs a stable snapshot. - let view = windowed.get(state); - if (!view) { - view = { - ...state, - hasOlderHistory: true, - messages: state.messages.filter( - (row) => !("historyId" in row) || row.historyId !== editedRowId - ), - }; - windowed.set(state, view); - } - return view; - }); - await showWorkspace(app, app.workspaceId, app.metadata.name); - await waitFor( - () => - expect( - useWorkspaceStoreRaw().getWorkspaceState(app.workspaceId).isTranscriptCaughtUp - ).toBe(true), - LOAD_TOLERANT_WAIT - ); - // Let the caught-up render run ChatPane's row check; the edit stays. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 500)); - }); - expect(editTextarea(app)?.value).toBe("edited before switch"); - expect(getDraftStore().getText(scope)).toBe("unsent draft"); - } finally { - stateSpy?.mockRestore(); - await app.dispose(); - } - }, 120_000); - // An unsettled edit that loses its target keeps its contents in the workspace's draft, after // the unsent draft (#5801 review): here the workspace turns transcript-only mid-edit, and the // composer is replaced by the read-only notice. @@ -1103,3 +949,747 @@ describe("Edit refused because history changed (B8)", () => { } }, 120_000); }); + +/** 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 of A unmounts and remounts. */ +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); +} + +/** The history id of the user row that shows `content`. */ +function userRowId(workspaceId: string, content: string) { + const row = useWorkspaceStoreRaw() + .getWorkspaceState(workspaceId) + .messages.find((message) => message.type === "user" && message.content === content); + const id = row?.type === "user" ? row.historyId : undefined; + if (!id) throw new Error(`No user row "${content}"`); + return id; +} + +type SendSpy = jest.SpyInstance< + ReturnType, + Parameters +>; + +/** + * Hold edit sends (requests with an `editMessageId`) before the backend sees them, until + * released; other sends go through. `refuse`: the held edit is then refused with this error. + * `replyOnly`: the backend takes the edit at once (its row is replaced) and only the reply waits. + */ +function holdEditSends( + app: AppHarness, + refuse?: { type: "history-changed" | "unknown" }, + replyOnly = false +) { + const workspaceService = app.env.services.workspaceService; + const realSend = 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 = replyOnly ? await realSend(...args) : null; + await gate; + if (reply) return reply; + if (!refuse) return realSend(...args); + return refuse.type === "unknown" + ? Err({ type: "unknown", raw: "refused" }) + : Err({ type: "history-changed" }); + }); + return { release, spy }; +} + +const editRequests = (spy: SendSpy, editId: string) => + spy.mock.calls.filter(([, , options]) => options.editMessageId === editId).length; + +/** How many times `needle` occurs in `text`. */ +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +/** 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(mockReply(text, true), LOAD_TOLERANT_WAIT.timeout); + await app.chat.expectStreamComplete(); +} + +/** + * While an edit send is in flight, put a queued message (`text` with `note`) back into the + * composer with its Edit action: the note joins the composer's own note list during the send. + * The review panel hides while an edit send is in flight, so the restored text is the signal. + * `holdBusy`: the workspace is idle, so a held stream keeps it busy while `text` queues. + */ +async function restoreQueuedNoteDuringEditSend( + app: AppHarness, + text: string, + note: string, + holdBusy: boolean +) { + const session = app.env.services.workspaceService.getOrCreateSession(app.workspaceId); + const options = { model: "openai:gpt-5.2", agentId: "exec" } as const; + const holding = holdBusy + ? app.env.orpc.workspace.sendMessage({ + workspaceId: app.workspaceId, + message: "[mock:wait-start] hold the workspace busy", + options, + }) + : null; + await waitFor(() => expect(session.isBusy()).toBe(true), LOAD_TOLERANT_WAIT); + await app.env.orpc.workspace.sendMessage({ + workspaceId: app.workspaceId, + message: text, + options: { ...options, muxMetadata: { type: "normal", reviews: [review(note)] } }, + }); + await waitFor(() => expect(session.hasQueuedMessages()).toBe(true), LOAD_TOLERANT_WAIT); + await editQueuedMessage(app); + await waitFor(() => { + const values = [ + ...app.view.container.querySelectorAll( + '[data-component="ChatInputSection"] textarea' + ), + ].map((textarea) => textarea.value); + expect(values.some((value) => value.includes(text))).toBe(true); + }, LOAD_TOLERANT_WAIT); + if (holding) { + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await holding; + await app.chat.expectStreamComplete(); + } +} + +/** The composer's Send button. */ +const sendButton = (app: AppHarness) => + app.view.container.querySelector( + '[data-component="ChatInputSection"] button[aria-label="Send message"]' + ); + +/** + * Serve the workspace's state through `view` (one view object per store state, as + * useSyncExternalStore needs a stable snapshot): a replay as the transcript shows it. + */ +function replayView(app: AppHarness, view: (state: WorkspaceState) => WorkspaceState) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- called below with the store as `this` + const realGetState = WorkspaceStore.prototype.getWorkspaceState; + const views = new WeakMap(); + return jest.spyOn(WorkspaceStore.prototype, "getWorkspaceState").mockImplementation(function ( + this: WorkspaceStore, + workspaceId: string + ) { + const state = realGetState.call(this, workspaceId); + if (workspaceId !== app.workspaceId) return state; + let shown = views.get(state); + if (!shown) { + shown = view(state); + views.set(state, shown); + } + return shown; + }); +} + +/** Re-render ChatPane with the store's current view, and let its effects run. */ +async function rerenderTranscript(app: AppHarness) { + await act(async () => { + useWorkspaceStoreRaw().bumpState(app.workspaceId); + await new Promise((resolve) => setTimeout(resolve, 500)); + }); +} + +// A workspace switch remounts the composer (ChatPane keys it by workspace). The switch ends the +// open edit, as on main, and the edit's text, files and notes move once into that workspace's +// draft, after the unsent draft (#5808). No edit state survives a composer remount. +describe("A workspace switch ends an open edit (#5808)", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + test("an edit sent before a workspace switch is closed on return and is sent only once", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-sent-once" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-sent-once-other"); + const textarea = await startEditWithUnsentDraft(app, scope); + const editId = userRowId(app.workspaceId, "first message"); + const sends = holdEditSends(app); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy, editId)).toBe(1), LOAD_TOLERANT_WAIT); + + await switchAwayAndBack(app, other); + // No edit is open on return, so nothing can send the edit a second time. + expect(editTextarea(app)).toBeNull(); + await app.chat.expectInputValue("unsent draft", LOAD_TOLERANT_WAIT.timeout); + + sends.release(); + await app.chat.expectTranscriptContains("edited message", LOAD_TOLERANT_WAIT.timeout); + await app.chat.expectStreamComplete(); + expect(editRequests(sends.spy, editId)).toBe(1); + expect(editTextarea(app)).toBeNull(); + await expectUnsentDraftKept(app, scope); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an edit accepted after a workspace switch brings back only the pre-edit notes", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-accepted-notes" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-accepted-notes-other"); + await sendWithNote(app, "first message", "review-row", "row note"); + // The pre-edit draft has its own note list (P). + await restoreQueuedMessageWithNote(app, scope, "pre-edit note"); + await editRow(app, "first message"); + await waitFor(() => expect(notesShowing(app, "row note")).toBe(1), LOAD_TOLERANT_WAIT); + + const replies = holdEditSends(app, undefined, true); + await sendEdit(app, "[mock:wait-start] edited message", "first message"); + // During the send a queued message with note R goes back into the composer. + await restoreQueuedNoteDuringEditSend(app, "second follow-up", "restored note", false); + + // The edit is accepted after its composer unmounted. + await switchAwayAndBack(app, other); + replies.release(); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await app.chat.expectStreamComplete(); + await waitFor(() => { + expect(notesShowing(app, "pre-edit note")).toBe(1); + expect(notesShowing(app, "restored note")).toBe(1); + }, LOAD_TOLERANT_WAIT); + + // The next message carries P and R once each, and never the edit's own note. + await app.chat.send("final follow-up"); + const sent = await waitFor(() => { + const call = replies.spy.mock.calls.find(([, message]) => + message.endsWith("final follow-up") + ); + if (!call) throw new Error("final follow-up not sent"); + return call[1]; + }, LOAD_TOLERANT_WAIT); + expect(occurrences(sent, "pre-edit note")).toBe(1); + expect(occurrences(sent, "restored note")).toBe(1); + expect(occurrences(sent, "row note")).toBe(0); + await app.chat.expectStreamComplete(); + replies.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an edit whose history-changed refresh failed ends on a switch, with its text after the unsent draft", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-refresh-failed" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-refresh-failed-other"); + 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 + ); + + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + expect(composerText(app)).not.toContain("refreshing transcript"); + expect(composerText(app)).not.toContain("transcript refresh failed"); + await waitFor( + () => + expect(messageTextarea(app).value).toBe(joinDraftText("unsent draft", "edited message")), + LOAD_TOLERANT_WAIT + ); + expect(getDraftStore().getText(scope)).toBe(messageTextarea(app).value); + await waitFor(() => expect(sendButton(app)?.disabled).toBe(false), LOAD_TOLERANT_WAIT); + refreshSpy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // An edit cannot add files, so its file change is removing one of the message's files. + test("a workspace switch ends an open edit and keeps its text and files after the unsent draft, once", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-moves-contents" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-moves-contents-other"); + const fileInput = 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(fileInput, { + target: { + files: [ + new File(["# kept"], "edit-kept.md", { type: "text/markdown" }), + new File(["# removed"], "edit-removed.md", { type: "text/markdown" }), + ], + }, + }); + await waitFor(() => { + expect(composerText(app)).toContain("edit-kept.md"); + expect(composerText(app)).toContain("edit-removed.md"); + }, LOAD_TOLERANT_WAIT); + const textarea = await startEditWithUnsentDraft(app, scope); + await waitFor( + () => expect(composerText(app)).toContain("edit-removed.md"), + LOAD_TOLERANT_WAIT + ); + typeIntoEdit(textarea, "edited before switch"); + await waitFor(() => expect(textarea.value).toBe("edited before switch")); + const removeButton = [ + ...app.view.container.querySelectorAll( + '[data-component="ChatInputSection"] button[aria-label="Remove attachment"]' + ), + ].find((button) => button.parentElement?.textContent?.includes("edit-removed.md")); + if (!removeButton) throw new Error("Remove button of edit-removed.md not found"); + fireEvent.click(removeButton); + await waitFor( + () => expect(composerText(app)).not.toContain("edit-removed.md"), + LOAD_TOLERANT_WAIT + ); + + await switchAwayAndBack(app, other); + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + const expected = joinDraftText("unsent draft", "edited before switch"); + await waitFor(() => expect(messageTextarea(app).value).toBe(expected), LOAD_TOLERANT_WAIT); + const names = (attachments: { filename?: string; id: string }[]) => + attachments.map((attachment) => attachment.filename ?? attachment.id); + expect(getDraftStore().getText(scope)).toBe(expected); + expect(names(getDraftStore().getView(scope).attachments)).toEqual([ + "unsent.txt", + "edit-kept.md", + ]); + await getDraftStore().flush(scope); + const saved = await app.env.services.draftService.get(scope); + expect(saved.text).toBe(expected); + expect(saved.attachments).toHaveLength(2); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an edit refused after a workspace switch keeps its text, files and notes in that workspace's draft, once", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-refused" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-refused-other"); + const otherScope: DraftScope = { kind: "workspace", workspaceId: other.id }; + await attachComposerFile(app, "edit-file.md"); + const textarea = await startEditWithUnsentDraft(app, scope, "row note"); + const sends = holdEditSends(app, { type: "history-changed" }); + const refreshSpy = jest.spyOn(WorkspaceStore.prototype, "requestTranscriptRefresh"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor( + () => expect(sends.spy.mock.calls.some(([, , o]) => o.editMessageId)).toBe(true), + LOAD_TOLERANT_WAIT + ); + await restoreQueuedNoteDuringEditSend(app, "queued with note", "restored note", true); + + // The refusal arrives while the other workspace is shown. + await showWorkspace(app, other.id, other.name); + sends.release(); + await waitFor( + () => expect(getDraftStore().getText(scope)).toContain("edited message"), + LOAD_TOLERANT_WAIT + ); + await showWorkspace(app, app.workspaceId, app.metadata.name); + + expect(editTextarea(app)).toBeNull(); + const expected = joinDraftText("unsent draft", "edited message", "queued with note"); + await waitFor(() => expect(messageTextarea(app).value).toBe(expected), LOAD_TOLERANT_WAIT); + expect(getDraftStore().getText(scope)).toBe(expected); + const names = (attachments: { filename?: string; id: string }[]) => + attachments.map((attachment) => attachment.filename ?? attachment.id); + expect(names(getDraftStore().getView(scope).attachments)).toEqual([ + "unsent.txt", + "edit-file.md", + ]); + await waitFor(() => { + expect(notesShowing(app, "row note")).toBe(1); + expect(notesShowing(app, "restored note")).toBe(1); + }, LOAD_TOLERANT_WAIT); + expect(getDraftStore().getText(otherScope)).toBe(""); + // The unmounted composer starts no transcript refresh for its dead edit. + expect(refreshSpy.mock.calls.filter(([id]) => id === app.workspaceId)).toHaveLength(0); + refreshSpy.mockRestore(); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("a workspace switch moves an idle edit's notes to the attached notes, once", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-moves-notes" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-moves-notes-other"); + const textarea = await startEditWithUnsentDraft(app, scope, "row note"); + await waitFor(() => expect(notesShowing(app, "row note")).toBe(1), LOAD_TOLERANT_WAIT); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + + await switchAwayAndBack(app, other); + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + await waitFor(() => expect(notesShowing(app, "row note")).toBe(1), LOAD_TOLERANT_WAIT); + expect(getDraftStore().getText(scope)).toBe(joinDraftText("unsent draft", "edited message")); + } finally { + await app.dispose(); + } + }, 120_000); + + // Main keeps one edit slot in ChatPane: a late cancel from A's unmounted composer would + // close the edit the user opened in B meanwhile. + test("an edit accepted after a switch does not close an edit opened in the other workspace", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-keeps-other-edit" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-keeps-other-edit-other"); + // Visit B first: it keeps cached rows, so ChatPane stays mounted across the switches. + 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); + + const textarea = await startEditWithUnsentDraft(app, scope); + const editId = userRowId(app.workspaceId, "first message"); + const sends = holdEditSends(app); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(editRequests(sends.spy, editId)).toBe(1), LOAD_TOLERANT_WAIT); + + 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(); + // Wait for A's held edit send to return, then for its completion to run. + await Promise.all(sends.spy.mock.results.map((result) => result.value as Promise)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + expect(editTextarea(app)?.value).toBe("edited other message"); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an editing /compact accepted after a switch never shows its command text in the draft", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-compact" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-compact-other"); + const textarea = await startEditWithUnsentDraft(app, scope); + // Hold the compaction request: the command keeps its text in the edit until accepted. + const workspaceService = app.env.services.workspaceService; + const realSend = workspaceService.sendMessage.bind(workspaceService); + let releaseSend: () => void = () => undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + const sendSpy = jest + .spyOn(workspaceService, "sendMessage") + .mockImplementation(async (...args: Parameters) => { + await sendGate; + return realSend(...args); + }); + typeIntoEdit(textarea, "/compact -t 500"); + await waitFor(() => expect(textarea.value).toBe("/compact -t 500")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(sendSpy).toHaveBeenCalled(), LOAD_TOLERANT_WAIT); + + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + expect(getDraftStore().getText(scope)).toBe("unsent draft"); + releaseSend(); + await app.chat.expectStreamComplete(60_000); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + await expectUnsentDraftKept(app, scope); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("under StrictMode an accepted edit closes, and a switch moves an idle edit once", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-strict", strictMode: true }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-strict-other"); + const textarea = await startEditWithUnsentDraft(app, scope); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await app.chat.expectTranscriptContains("edited message", LOAD_TOLERANT_WAIT.timeout); + await app.chat.expectStreamComplete(); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await expectUnsentDraftKept(app, scope); + + await editRow(app, "edited message"); + typeIntoEdit(editTextarea(app)!, "edited again"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("edited again")); + await switchAwayAndBack(app, other); + expect(editTextarea(app)).toBeNull(); + const expected = joinDraftText("unsent draft", "edited again"); + await waitFor(() => expect(messageTextarea(app).value).toBe(expected), LOAD_TOLERANT_WAIT); + expect(getDraftStore().getText(scope)).toBe(expected); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an edit refused after its row was deleted keeps its text after the unsent draft, once", async () => { + const app = await createAppHarness({ branchPrefix: "edit-refused-row-deleted" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const sends = holdEditSends(app, { type: "unknown" }); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor( + () => expect(sends.spy.mock.calls.some(([, , o]) => o.editMessageId)).toBe(true), + LOAD_TOLERANT_WAIT + ); + const cleared = await app.env.services.workspaceService.truncateHistory(app.workspaceId); + expect(cleared.success).toBe(true); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + + sends.release(); + await expectEditKeptAsDraft(app, scope); + expect(getDraftStore().getText(scope)).toBe(joinDraftText("unsent draft", "edited message")); + sends.spy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an edit whose row is deleted keeps its notes as attached notes after a switch", async () => { + const app = await createAppHarness({ branchPrefix: "edit-row-deleted-notes" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "edit-row-deleted-notes-other"); + const textarea = await startEditWithUnsentDraft(app, scope, "row note"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.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 expectEditKeptAsDraft(app, scope); + + await switchAwayAndBack(app, other); + await switchAwayAndBack(app, other); + await waitFor(() => expect(notesShowing(app, "row note")).toBe(1), LOAD_TOLERANT_WAIT); + } finally { + await app.dispose(); + } + }, 120_000); + + test("a second Edit with notes, then Cancel, shows the first edit's notes once", async () => { + const app = await createAppHarness({ branchPrefix: "second-edit-notes-cancel" }); + try { + await sendWithNote(app, "earlier message", "review-earlier", "second row note"); + await sendWithNote(app, "first message", "review-first", "first row note"); + await editRow(app, "first message"); + typeIntoEdit(editTextarea(app)!, "edited message"); + await waitFor(() => expect(editTextarea(app)?.value).toBe("edited message")); + + await editRow(app, "earlier message"); + await waitFor(() => expect(notesShowing(app, "second row note")).toBe(1), LOAD_TOLERANT_WAIT); + fireEvent.keyDown(editTextarea(app)!, { key: "Escape" }); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + await waitFor(() => expect(notesShowing(app, "first row note")).toBe(1), LOAD_TOLERANT_WAIT); + expect(notesShowing(app, "second row note")).toBe(0); + + // The note is sent once, and then it is done: it does not stay attached. + const sendSpy = jest.spyOn(app.env.services.workspaceService, "sendMessage"); + await app.chat.send("follow-up"); + const sent = await waitFor(() => { + const call = sendSpy.mock.calls.find(([, message]) => message.endsWith("follow-up")); + if (!call) throw new Error("follow-up not sent"); + return call[1]; + }, LOAD_TOLERANT_WAIT); + expect(occurrences(sent, "first row note")).toBe(1); + await app.chat.expectStreamComplete(); + await waitFor(() => expect(notesShowing(app, "first row note")).toBe(0), LOAD_TOLERANT_WAIT); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + test("an edit cancelled while its send is still preparing sends nothing", async () => { + const app = await createAppHarness({ branchPrefix: "edit-cancel-preparing" }); + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const sendSpy: 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" }); + const composer = app.view.container.querySelector( + '[data-component="ChatInputSection"]' + )!; + fireEvent.click(within(composer).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(editTextarea(app)).toBeNull(), LOAD_TOLERANT_WAIT); + + save.release(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + }); + expect(sendSpy.mock.calls.filter(([, , o]) => o.editMessageId)).toHaveLength(0); + // The send settled: Edit works again. + await waitFor( + () => expect(rowEditButton(app, "first message")?.disabled).toBe(false), + LOAD_TOLERANT_WAIT + ); + await expectUnsentDraftKept(app, scope); + save.spy.mockRestore(); + sendSpy.mockRestore(); + } finally { + await app.dispose(); + } + }, 120_000); + + // A replay of the shown workspace can empty its rows while ChatPane stays mounted: an open + // edit must not close because its row is briefly missing. + test("an open edit stays open while its workspace replays and has not caught up", async () => { + const app = await createAppHarness({ branchPrefix: "edit-replay-not-caught-up" }); + let stateSpy: jest.SpyInstance | null = null; + 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")); + stateSpy = replayView(app, (state) => ({ + ...state, + isTranscriptCaughtUp: false, + messages: [], + })); + await rerenderTranscript(app); + expect(editTextarea(app)?.value).toBe("edited message"); + expect(getDraftStore().getText(scope)).toBe("unsent draft"); + } finally { + stateSpy?.mockRestore(); + await app.dispose(); + } + }, 120_000); + + // A windowed replay (#4961) can load only the newest rows: the edit's row is then older + // history, not gone. + test("an open edit stays open when a replay leaves its row outside the window", async () => { + const app = await createAppHarness({ branchPrefix: "edit-replay-windowed" }); + let stateSpy: jest.SpyInstance | null = null; + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const textarea = await startEditWithUnsentDraft(app, scope); + const editId = userRowId(app.workspaceId, "first message"); + typeIntoEdit(textarea, "edited message"); + await waitFor(() => expect(textarea.value).toBe("edited message")); + stateSpy = replayView(app, (state) => ({ + ...state, + hasOlderHistory: true, + messages: state.messages.filter((row) => !("historyId" in row) || row.historyId !== editId), + })); + await rerenderTranscript(app); + expect(editTextarea(app)?.value).toBe("edited message"); + expect(getDraftStore().getText(scope)).toBe("unsent draft"); + } finally { + stateSpy?.mockRestore(); + await app.dispose(); + } + }, 120_000); + + // The switch, not the replay, decides: the edit ends even though the returning replay keeps + // its row in older history, and its text joins the draft once. + test("a workspace switch ends an open edit whose row a replay leaves outside the window", async () => { + const app = await createAppHarness({ branchPrefix: "switch-edit-row-windowed" }); + let stateSpy: jest.SpyInstance | null = null; + try { + const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; + const other = await addOtherWorkspace(app, "switch-edit-row-windowed-other"); + // Visit B first: ChatPane then stays mounted across the switches, and only the switch + // itself ends the edit. + await switchAwayAndBack(app, other); + const textarea = await startEditWithUnsentDraft(app, scope); + const editId = userRowId(app.workspaceId, "first message"); + typeIntoEdit(textarea, "edited before switch"); + await waitFor(() => expect(textarea.value).toBe("edited before switch")); + await showWorkspace(app, other.id, other.name); + stateSpy = replayView(app, (state) => ({ + ...state, + hasOlderHistory: true, + messages: state.messages.filter((row) => !("historyId" in row) || row.historyId !== editId), + })); + await showWorkspace(app, app.workspaceId, app.metadata.name); + await waitFor( + () => + expect( + useWorkspaceStoreRaw().getWorkspaceState(app.workspaceId).isTranscriptCaughtUp + ).toBe(true), + LOAD_TOLERANT_WAIT + ); + await rerenderTranscript(app); + expect(editTextarea(app)).toBeNull(); + const expected = joinDraftText("unsent draft", "edited before switch"); + await waitFor(() => expect(messageTextarea(app).value).toBe(expected), LOAD_TOLERANT_WAIT); + await switchAwayAndBack(app, other); + await rerenderTranscript(app); + expect(editTextarea(app)).toBeNull(); + expect(getDraftStore().getText(scope)).toBe(expected); + expect( + getDraftStore() + .getView(scope) + .attachments.map(({ id }) => id) + ).toEqual(["file-unsent"]); + } finally { + stateSpy?.mockRestore(); + await app.dispose(); + } + }, 120_000); +}); diff --git a/tests/ui/harness/createAppHarness.ts b/tests/ui/harness/createAppHarness.ts index 238daf8f66..62d506b5a7 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 43f6ab6052..0ab6971ebb 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 5a9e6e33e10943c02c60126239997a67db35e023 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 06:38:57 +0000 Subject: [PATCH 13/15] fix: a workspace switch ends the edit; its text, files and notes move once to the draft (#5808) --- src/browser/components/ChatPane/ChatPane.tsx | 81 +++----- src/browser/features/ChatInput/index.tsx | 175 ++++++++++-------- .../features/ChatInput/useComposerDraft.ts | 103 +++-------- 3 files changed, 148 insertions(+), 211 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 9c8fcc5541..8b1eb75700 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -6,7 +6,6 @@ import React, { useRef, useDeferredValue, useMemo, - useSyncExternalStore, } from "react"; import { Lightbulb } from "lucide-react"; import { Skeleton } from "@/browser/components/Skeleton/Skeleton"; @@ -42,7 +41,6 @@ import { import { getRetryBarrierDerivation } from "./retryBarrierDerivation"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { ChatInput, type ChatInputAPI } from "@/browser/features/ChatInput/index"; -import { keepUnsettledEditInDraft } from "@/browser/features/ChatInput/useComposerDraft"; import type { QueueDispatchMode } from "@/browser/features/ChatInput/types"; import { mergeConsecutiveStreamErrors, @@ -313,49 +311,6 @@ export const ChatPane: React.FC = (props) => { ); }; -// The open edit per workspace, in module memory: a workspace switch keeps it, so switching back -// shows the edit with its typed changes, whose buffer the composer keeps (#5808). Not React -// state: the shell unmounts this pane while a workspace loads. Memory only, so a reload drops -// it. An entry goes when its edit ends; one left by a workspace removed mid-edit is small and -// stays until reload. -let editTargets: Readonly> = {}; -const editTargetListeners = new Set<() => void>(); -const readEditTargets = () => editTargets; -function subscribeEditTargets(listener: () => void) { - editTargetListeners.add(listener); - return () => { - editTargetListeners.delete(listener); - }; -} -function setEditingByWorkspace( - update: ( - previous: Readonly> - ) => Readonly> -) { - const previous = editTargets; - const next = update(previous); - if (next === previous) return; - editTargets = next; - for (const listener of editTargetListeners) listener(); - // Every cleared or replaced target goes through here, so its unsettled contents are kept. - for (const [workspaceId, edit] of Object.entries(previous)) { - if (next[workspaceId]?.id !== edit.id) keepUnsettledEditInDraft(workspaceId, edit.id); - } -} - -/** `edits` with this workspace's edit set or removed; the same object when nothing changes. */ -function withWorkspaceEdit( - edits: Readonly>, - workspaceId: string, - edit: EditingMessageState | undefined -): Readonly> { - if (edits[workspaceId] === edit) return edits; - if (edit) return { ...edits, [workspaceId]: edit }; - if (!(workspaceId in edits)) return edits; - const { [workspaceId]: _removed, ...rest } = edits; - return rest; -} - const ChatPaneContent: React.FC = (props) => { const { workspaceId, @@ -423,24 +378,30 @@ const ChatPaneContent: React.FC = (props) => { ? queuedActionErrorState.error : null; - const editingByWorkspace = useSyncExternalStore(subscribeEditTargets, readEditTargets); - const editingMessage = editingByWorkspace[workspaceId]; + const [editingState, setEditingState] = useState(() => ({ + workspaceId, + message: undefined as EditingMessageState | undefined, + })); + const editingMessage = + editingState.workspaceId === workspaceId ? editingState.message : undefined; const setEditingMessage = useCallback( (message: EditingMessageState | undefined) => { // Any change of edit target ends the conflict recovery of the previous edit (no-op // when none is pending); only the composer's own updater keeps a request alive. storeRaw.cancelTranscriptRefresh(workspaceId); - setEditingByWorkspace((previous) => - withWorkspaceEdit(previous, workspaceId, transcriptOnly ? undefined : message) - ); + setEditingState({ + workspaceId, + message: transcriptOnly ? undefined : message, + }); }, [storeRaw, workspaceId, transcriptOnly] ); const updateEditingMessage = (update: (current: EditingMessageState) => EditingMessageState) => { - setEditingByWorkspace((previous) => { - const current = previous[workspaceId]; - return current ? withWorkspaceEdit(previous, workspaceId, update(current)) : previous; - }); + setEditingState((previous) => + previous.workspaceId === workspaceId && previous.message + ? { ...previous, message: update(previous.message) } + : previous + ); }; // The workspace with an unresolved edit send: no edit starts there meanwhile (#5226). const [editSendPendingIn, setEditSendPendingIn] = useState(null); @@ -473,7 +434,7 @@ const ChatPaneContent: React.FC = (props) => { // stale edit state instead of leaving the transcript stuck at an edit cutoff. useEffect(() => { if (transcriptOnly && editingMessage) { - setEditingByWorkspace((previous) => withWorkspaceEdit(previous, workspaceId, undefined)); + setEditingState({ workspaceId, message: undefined }); } }, [editingMessage, transcriptOnly, workspaceId]); @@ -929,8 +890,8 @@ const ChatPaneContent: React.FC = (props) => { clearBackgroundBashError(); }, [clearBackgroundBashError, workspaceId]); - // A switch keeps each workspace's open edit (editingByWorkspace). useEffect(() => { + setEditingState({ workspaceId, message: undefined }); setPendingTimelineReveal(null); }, [workspaceId]); @@ -1393,8 +1354,8 @@ const ChatPaneContent: React.FC = (props) => { // Must be before early return to satisfy React Hooks rules useEffect(() => { if (!workspaceState || !editingMessage) return; - // Back from another workspace, a kept edit's rows replay first: only a caught-up - // transcript can show that its row is gone (#5808). + // A replay of this workspace empties its rows first, while ChatPane stays mounted: only a + // caught-up transcript can show that the edited row is gone (#5808). if (!workspaceState.isTranscriptCaughtUp) return; // Conflict recovery re-reads the transcript (a full replay empties the aggregator first, // a pre-window range discards cached pages); the refresh outcome decides whether the @@ -1416,8 +1377,8 @@ const ChatPaneContent: React.FC = (props) => { )?.historyId; if (!editCutoffHistoryId) { - // A windowed replay can leave a kept edit's row in older history, unloaded: that is not - // a deletion, so the edit stays (#5808). Its send still checks the rows (precondition). + // A windowed replay can leave the edited row in older history, unloaded: that is not a + // deletion, so the edit stays (#5808). Its send still checks the rows (precondition). if (workspaceState.hasOlderHistory) return; // Message was replaced or deleted - clear editing state setEditingMessage(undefined); diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 3929f839d6..79efa10d70 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -321,24 +321,6 @@ interface EditSession { settled: boolean; /** An edit send for it has not returned yet: that send settles it, not releaseEndedEdit. */ sendInFlight: boolean; - /** The edit's notes when its composer unmounted (a workspace switch), restored on return. */ - editReviews: ReviewNoteDataForDisplay[] | null; -} - -// The open edit's session per workspace, next to its buffer (useComposerDraft): both outlive -// the composer, which a workspace switch remounts while ChatPane keeps the edit open (#5808). -// Memory only, like the buffer. An entry goes when its edit settles; one left by a workspace -// removed mid-edit is small and stays until reload. -const editSessions = new Map(); -function keepEditReviews(session: EditSession | null, reviews: ReviewNoteDataForDisplay[] | null) { - if (session && !session.settled) session.editReviews = reviews; -} -/** Settle an edit session: it restores nothing again, and its workspace forgets it. */ -function settleEditSession(session: EditSession) { - session.settled = true; - for (const [workspaceId, kept] of editSessions) { - if (kept === session) editSessions.delete(workspaceId); - } } const ChatInputInner: React.FC = (props) => { @@ -536,6 +518,7 @@ const ChatInputInner: React.FC = (props) => { const { getDraft, setDraft, getLiveText, beginEditDraft, endEditDraft, updateEditDraft } = draft; const { reviewOverrideActive, reviewData, reviewIdsForCheck, reviewPanelItems } = draft; const { removeDraftReview, updateDraftReviewNote, draftScope, latestInputValueRef } = draft; + const { draftReviewsRef } = draft; const { processingAttachmentCount, handlePaste, @@ -559,9 +542,14 @@ const ChatInputInner: React.FC = (props) => { // Creation sends can resolve after navigation; guard draft clears on unmounted inputs. const isMountedRef = useRef(true); + const releaseEndedEditRef = useRef<() => void>(() => undefined); useEffect(() => { + // Set here too: StrictMode runs this cleanup once at mount, and the composer lives on. + isMountedRef.current = true; return () => { isMountedRef.current = false; + // An unmount (workspace switch, transcript-only) ends the edit (#5808). + releaseEndedEditRef.current(); }; }, []); const inputRef = useRef(null); @@ -1268,18 +1256,11 @@ 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( - workspaceId ? (editSessions.get(workspaceId) ?? null) : null - ); - // Live review override for completions that settle after the render they started in. - const draftReviewsRef = useRef(draftReviews); - useLayoutEffect(() => { - draftReviewsRef.current = draftReviews; - }); + const editSessionRef = useRef(null); const restorePreEditDraft = () => { const session = editSessionRef.current; if (!session || session.settled || session.id !== editingMessageIdRef.current) return; - settleEditSession(session); + session.settled = true; // The edit text is dropped; the composer shows the unsent draft again. endEditDraft(); setDraftReviews(session.preEditReviews); @@ -1296,18 +1277,17 @@ const ChatInputInner: React.FC = (props) => { dropEditReviews = false ): boolean => { if (!session || session.settled) return false; - settleEditSession(session); - const { preEditReviews } = session; if (dropEditReviews) setDraftReviews(null); + // Its composer unmounted (a workspace switch): every note goes to the review store. + if (!isMountedRef.current) { + moveEditToDraft(session); + return true; + } + session.settled = true; + const { preEditReviews } = session; // The composer goes back to the unsent draft; what was typed in the edit buffer while the // send was in flight joins it after, never replacing it. - const typedDuringSend = endEditDraft(); - if (typedDuringSend && typedDuringSend.text.trim().length > 0) { - setInput((current) => joinDraftText(current, typedDuringSend.text)); - } - if (typedDuringSend && typedDuringSend.attachments.length > 0) { - setAttachments((current) => [...current, ...typedDuringSend.attachments]); - } + appendToDraft(endEditDraft()); if (preEditReviews !== null) { if ((dropEditReviews || draftReviewsRef.current === null) && onAddReviewForRestore) { // The notes in effect live in the review store: add the restored ones there too. @@ -1321,28 +1301,47 @@ const ChatInputInner: React.FC = (props) => { // By identity, not row id: a row reopened after a cancel is a new edit. const isOpenEditOrNone = (session: EditSession | null) => editingMessageIdRef.current === undefined || editSessionRef.current === session; - // ChatPane can end an edit without a composer handler running: its row was deleted or - // replaced, or a history-changed refresh found no target. The edit's text, attachments and - // notes then stay as a normal draft, after the unsent draft, so nothing typed is lost (on - // main they were the draft). An edit whose send is in flight is left to that send: an - // accepted edit replaces its row before the reply, and that is not a cancel. + // An unmounted composer's edit already ended: ChatPane's one edit slot may hold another + // workspace's edit by now, which a cancel from here would close. + const mayCancelEdit = (session: EditSession | null) => + isMountedRef.current && isOpenEditOrNone(session); + /** Text after the unsent draft, files after its files: never over it. */ + const appendToDraft = (contents: { text: string; attachments: ChatAttachment[] } | null) => { + if (contents && contents.text.trim().length > 0) { + getDraftStore().setText(draftScope, (current) => joinDraftText(current, contents.text)); + } + if (contents && contents.attachments.length > 0) { + getDraftStore().setAttachments(draftScope, (current) => [ + ...current, + ...contents.attachments, + ]); + } + }; + // The one move of an edit into the workspace's draft (#5808): settle, then take the buffer, so + // a second call finds nothing. Text and files go after the unsent draft; the pre-edit and edit + // notes go to the review store. Mounted or not: the composer belongs to one workspace. + const moveEditToDraft = (session: EditSession) => { + session.settled = true; + appendToDraft(endEditDraft()); + const notes = [...(session.preEditReviews ?? []), ...(draftReviewsRef.current ?? [])]; + for (const review of notes) onAddReviewForRestore?.(review); + setDraftReviews(null); + }; + // An edit that ended unsettled moves to the draft: its composer unmounted, or ChatPane + // cleared or replaced its target (row deleted, no refresh target, a second Edit). An edit send + // in flight keeps it: an accepted edit replaces its row before the reply. const releaseEndedEdit = () => { const session = editSessionRef.current; - if (!session || session.sendInFlight || editingMessageIdRef.current !== undefined) return; - restorePreEditDraftAfterSend(session); + if (!session || session.settled || session.sendInFlight) return; + if (isMountedRef.current && editingMessageIdRef.current === session.id) return; + moveEditToDraft(session); }; const markEditSendInFlight = (session: EditSession | null, inFlight: boolean) => { if (session) session.sendInFlight = inFlight; }; - // A workspace switch unmounts this composer while the edit stays open: keep the edit's notes - // in its session, as its text and files are kept in the buffer (#5808). - useEffect(() => { - const sessionRef = editSessionRef; - const reviewsRef = draftReviewsRef; - return () => keepEditReviews(sessionRef.current, reviewsRef.current); - }, []); // After every commit: the edit's end arrives as a prop change, and settled sessions no-op. useEffect(() => { + releaseEndedEditRef.current = releaseEndedEdit; releaseEndedEdit(); }); @@ -1464,25 +1463,18 @@ const ChatInputInner: React.FC = (props) => { } if (appliedEditIdRef.current === editingMessage.id) return; appliedEditIdRef.current = editingMessage.id; - const kept = editSessionRef.current; - // Back from another workspace, the edit's session and buffer outlived the composer: keep - // them (and the pre-edit notes) instead of starting the edit over. - if (!kept || kept.id !== editingMessage.id || kept.settled) { - const session: EditSession = { - id: editingMessage.id, - preEditReviews: draftReviews, - settled: false, - sendInFlight: false, - editReviews: null, - }; - editSessionRef.current = session; - if (workspaceId) editSessions.set(workspaceId, session); - } + // A second Edit: the after-commit effect above moved the previous one, so read the ref. + editSessionRef.current = { + id: editingMessage.id, + preEditReviews: draftReviewsRef.current, + settled: false, + sendInFlight: false, + }; beginEditDraft(editingMessage.id, { text: editingMessage.pending.content, attachments: pendingChatAttachments(editingMessage.pending, `edit-${editingMessage.id}`), }); - setDraftReviews(editSessionRef.current?.editReviews ?? editingMessage.pending.reviews); + setDraftReviews(editingMessage.pending.reviews); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -1492,7 +1484,7 @@ const ChatInputInner: React.FC = (props) => { inputRef.current.focus(); } }, 0); - }, [editingMessage, draftReviews, beginEditDraft, setDraftReviews, workspaceId]); + }, [editingMessage, draftReviewsRef, beginEditDraft, setDraftReviews]); // Project live workflow run cards for foreground slash invocations after reloads. useEffect(() => { @@ -2160,7 +2152,7 @@ const ChatInputInner: React.FC = (props) => { case "cancel-edit": // Emitted once an editing command (/compact) was accepted: the edit is complete. restoredPreEditDraft = restorePreEditDraftAfterSend(commandEditSession, true); - if (isOpenEditOrNone(commandEditSession)) commandOnCancelEdit?.(); + if (mayCancelEdit(commandEditSession)) commandOnCancelEdit?.(); break; case "edit-history-changed": startEditTranscriptRefresh(action.editMessageId, action.precondition); @@ -2227,7 +2219,8 @@ const ChatInputInner: React.FC = (props) => { editMessageId: string, precondition: HistoryEditPrecondition ): void => { - if (variant !== "workspace") return; + // An unmounted composer's edit ended with it. + if (variant !== "workspace" || !isMountedRef.current) return; const targetWorkspaceId = props.workspaceId; const onEditingMessageChange = props.onEditingMessageChange; const onCancelEdit = props.onCancelEdit; @@ -2735,12 +2728,11 @@ const ChatInputInner: React.FC = (props) => { // 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) => - hasDraftBlock(current, text) ? current : joinDraftText(text, current) - ); + const withSentText = (current: string) => + 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) => { + const withSentAttachments = (current: ChatAttachment[]) => { const sentById = new Map( sendAttachments.map((attachment) => [attachment.id, attachment]) ); @@ -2749,7 +2741,36 @@ const ChatInputInner: React.FC = (props) => { const merged = current.map((attachment) => sentById.get(attachment.id) ?? attachment); const changed = merged.some((attachment, index) => attachment !== current[index]); return missing.length > 0 || changed ? [...missing, ...merged] : current; - }); + }; + if (!editSessionForSend) { + setInput(withSentText); + setAttachments(withSentAttachments); + return; + } + // A refused edit goes back into its own buffer while unsettled, shown or not. Else after + // the unsent draft: setInput could reach a newer edit's buffer (#5808). + const backInEdit = + !editSessionForSend.settled && + updateEditDraft(editSessionForSend.id, (edit) => ({ + text: withSentText(edit.text), + attachments: withSentAttachments(edit.attachments), + })); + if (!backInEdit) appendToDraft({ text, attachments: sendAttachments }); + }; + const editSendIsLive = () => + editSessionForSend?.settled === false && editSessionRef.current === editSessionForSend; + // A refused edit's notes follow its session: in front of its notes (never twice), or to + // the review store once it is gone, never into another edit's or the draft's override. + const putBackSendReviews = () => { + if (!editSessionForSend) setDraftReviews(preSendReviews); + else if (!editSendIsLive()) { + for (const review of preSendReviews ?? []) onAddReviewForRestore?.(review); + } else if (preSendReviews !== null) { + setDraftReviews((current) => [ + ...preSendReviews.filter((review) => !current?.includes(review)), + ...(current ?? []), + ]); + } }; const preSendReviews = draftReviews; const editMessageForSend = editingMessageForUi; @@ -2902,6 +2923,8 @@ const ChatInputInner: React.FC = (props) => { pushToast({ type: "error", message: TRANSCRIPT_NOT_CAUGHT_UP_MESSAGE }); return; } + // An edit cancelled (or moved to the draft) while this send prepared sends nothing. + if (editMessageForSend && !editSendIsLive()) return; // Idempotent sends (formal/composer-drafts/ComposerSends.tla, FixRenderer): every send // carries an id minted here; a retry reuses it with the same request, so the backend @@ -3019,7 +3042,7 @@ const ChatInputInner: React.FC = (props) => { // through its draft entry instead) setOptimisticallyDismissedEditId(null); putBackTaken(); - setDraftReviews(preSendReviews); + putBackSendReviews(); // 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). @@ -3079,7 +3102,7 @@ const ChatInputInner: React.FC = (props) => { // Exit editing mode if we were editing restorePreEditDraftAfterSend(editSessionForSend); - if (editMessageForSend && props.onCancelEdit && isOpenEditOrNone(editSessionForSend)) { + if (editMessageForSend && props.onCancelEdit && mayCancelEdit(editSessionForSend)) { props.onCancelEdit(); } else if (editMessageForSend) { setOptimisticallyDismissedEditId(null); @@ -3099,7 +3122,7 @@ const ChatInputInner: React.FC = (props) => { // Restore draft on error setOptimisticallyDismissedEditId(null); putBackTaken(); - setDraftReviews(preSendReviews); + putBackSendReviews(); }; await runWithCatchFinally(sendPreparedMessage, restoreDraftOnError, () => { setSendingCount((c) => c - 1); diff --git a/src/browser/features/ChatInput/useComposerDraft.ts b/src/browser/features/ChatInput/useComposerDraft.ts index dceda66e3d..970a844b7c 100644 --- a/src/browser/features/ChatInput/useComposerDraft.ts +++ b/src/browser/features/ChatInput/useComposerDraft.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { defaultCreationDraftScope, getDraftStore, @@ -7,9 +7,7 @@ import { } from "@/browser/stores/DraftStore"; import type { ReviewNoteDataForDisplay } from "@/common/types/message"; import type { Review } from "@/common/types/review"; -import assert from "@/common/utils/assert"; import { DRAFT_ID_PATTERN } from "@/constants/drafts"; -import { joinDraftText } from "@/common/utils/composerDraftText"; import type { ChatAttachment } from "./ChatAttachments"; import type { Toast } from "./ChatInputToast"; @@ -53,47 +51,7 @@ interface EditDraft { text: string; attachments: ChatAttachment[]; } - -// The open edit's buffer per workspace, in module memory. A workspace switch remounts the -// composer (ChatPane keys it by workspace) while ChatPane keeps the edit open, so the buffer -// must outlive the composer (#5808). Module memory, not the draft store: a reload still drops -// the edit, and another window never sees it (#5672, #5571). -const editDrafts = new Map(); -const editDraftListeners = new Map void>>(); -function writeStoredEditDraft(key: string, next: EditDraft | null) { - if (next) editDrafts.set(key, next); - else editDrafts.delete(key); - for (const listener of editDraftListeners.get(key) ?? []) listener(); -} -function subscribeEditDraft(key: string, listener: () => void) { - const listeners = editDraftListeners.get(key) ?? new Set<() => void>(); - editDraftListeners.set(key, listeners); - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0) editDraftListeners.delete(key); - }; -} - -/** - * The one rule for an edit that loses its target without a settle (cancel and accepted sends - * settle first and leave no buffer): ChatPane calls this whenever a workspace's edit target is - * cleared or replaced (transcript-only, a second Edit, a deleted row, a refresh that finds no - * target). The edit's text and files stay in the workspace's normal draft, after the unsent - * draft, never over it. Taking the buffer first makes it exactly once. - */ -export function keepUnsettledEditInDraft(workspaceId: string, editId: string) { - const edit = editDrafts.get(workspaceId); - if (edit?.editId !== editId) return; - writeStoredEditDraft(workspaceId, null); - const scope: DraftStoreScope = { kind: "workspace", workspaceId }; - if (edit.text.trim().length > 0) { - getDraftStore().setText(scope, (current) => joinDraftText(current, edit.text)); - } - if (edit.attachments.length > 0) { - getDraftStore().setAttachments(scope, (current) => [...current, ...edit.attachments]); - } -} +type EditPatch = Partial>; type Update = T | ((previous: T) => T); const applyUpdate = (value: Update, previous: T): T => @@ -109,28 +67,21 @@ export function useComposerDraft(options: UseComposerDraftOptions) { // While a message is edited, the composer edits this buffer instead of the draft: the edit // text stays in this window's memory, so a reload keeps the unsent draft (#5672) and another // window never shows the edit (#5571). A reload drops the edit; that is the chosen tradeoff. - // Edits exist only in a workspace composer, so the workspace keys the buffer (editDrafts). - const editKey = options.variant === "workspace" ? options.workspaceId : null; - const readEditDraft = () => (editKey ? (editDrafts.get(editKey) ?? null) : null); - const editDraft = useSyncExternalStore( - (listener) => (editKey ? subscribeEditDraft(editKey, listener) : () => undefined), - readEditDraft - ); + // The ref is the live copy for writes that run after an await; renders read the state. + const [editDraft, setEditDraftState] = useState(null); + const editDraftRef = useRef(null); const editIdRef = useRef(options.editMessageId); useLayoutEffect(() => { editIdRef.current = options.editMessageId; }); const writeEditDraft = (next: EditDraft | null) => { - if (!editKey) { - assert(next === null, "An edit buffer needs a workspace composer"); - return; - } - writeStoredEditDraft(editKey, next); + editDraftRef.current = next; + setEditDraftState(next); }; // Only the open edit's buffer counts. One left behind by an edit that ended without settling - // (its row was replaced) is ignored until the composer releases it. + // (its row was replaced) is ignored until the composer moves it to the draft. const liveEditDraft = () => { - const current = readEditDraft(); + const current = editDraftRef.current; return current !== null && current.editId === editIdRef.current ? current : null; }; const editActive = editDraft !== null && editDraft.editId === options.editMessageId; @@ -182,7 +133,14 @@ export function useComposerDraft(options: UseComposerDraftOptions) { .catch(() => undefined); }; }, [variant, workspaceId, creationProjectPath, pendingDraftId]); - const [draftReviews, setDraftReviews] = useState(null); + const [draftReviews, setDraftReviewsState] = useState(null); + // Written with the state, so an edit send that completes after its composer unmounted still + // reads (and moves) the notes it put back (#5808). Never written on an edit keystroke. + const draftReviewsRef = useRef(draftReviews); + const setDraftReviews = (value: Update) => { + draftReviewsRef.current = applyUpdate(value, draftReviewsRef.current); + setDraftReviewsState(draftReviewsRef.current); + }; const draftReviewIdsRef = useRef(new WeakMap()); const nextDraftReviewIdRef = useRef(0); const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay => @@ -235,25 +193,19 @@ export function useComposerDraft(options: UseComposerDraftOptions) { setInput, /** The live composer text (the open edit's, else the draft's), for code after an await. */ getLiveText: () => liveEditDraft()?.text ?? draftStore.getText(draftScope), - /** - * Fill the edit buffer; from now on the composer edits it, not the draft. A buffer this edit - * already has (it outlived a workspace switch) is kept with its typed changes. - */ - beginEditDraft: (editId: string, next: { text: string; attachments: ChatAttachment[] }) => { - if (readEditDraft()?.editId === editId) return; - writeEditDraft({ editId, ...next }); - }, - /** - * Change this edit's buffer, whether or not ChatPane still shows the edit (an accepted edit - * command replaces its row before it clears the composer). Never the shared draft. - */ - updateEditDraft: (editId: string, patch: Partial>) => { - const edit = readEditDraft(); - if (edit?.editId === editId) writeEditDraft({ ...edit, ...patch }); + /** Fill the edit buffer; from now on the composer edits it, not the draft. */ + beginEditDraft: (editId: string, next: { text: string; attachments: ChatAttachment[] }) => + writeEditDraft({ editId, ...next }), + /** Change this edit's buffer, shown or not; never the draft. False if it is not this edit's. */ + updateEditDraft: (editId: string, update: EditPatch | ((edit: EditDraft) => EditPatch)) => { + const edit = editDraftRef.current; + if (edit?.editId !== editId) return false; + writeEditDraft({ ...edit, ...(typeof update === "function" ? update(edit) : update) }); + return true; }, /** Drop the edit buffer and return what it held (text typed during an edit send). */ endEditDraft: () => { - const edit = readEditDraft(); + const edit = editDraftRef.current; writeEditDraft(null); return edit; }, @@ -264,6 +216,7 @@ export function useComposerDraft(options: UseComposerDraftOptions) { attachments, setAttachments, draftReviews, + draftReviewsRef, setDraftReviews, getDraft, setDraft, From a0846a42fe610d8de9312c83c49b3bbb8a90869c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 06:59:09 +0000 Subject: [PATCH 14/15] tests: read settled notes from the review store; keep ChatPane mounted in the windowed-replay switch test --- tests/ui/chat/editKeepsUnsentDraft.test.ts | 44 +++++++++++++++------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/tests/ui/chat/editKeepsUnsentDraft.test.ts b/tests/ui/chat/editKeepsUnsentDraft.test.ts index 429e5358b7..d285b39197 100644 --- a/tests/ui/chat/editKeepsUnsentDraft.test.ts +++ b/tests/ui/chat/editKeepsUnsentDraft.test.ts @@ -12,6 +12,7 @@ import { act, fireEvent, waitFor, within } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { DraftStore, getDraftStore } from "@/browser/stores/DraftStore"; +import { getReviewStateStore } from "@/browser/stores/ReviewStateStore"; import { WorkspaceStore, useWorkspaceStoreRaw, @@ -968,6 +969,22 @@ async function switchAwayAndBack(app: AppHarness, other: { id: string; name: str 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 (no loading placeholder). + */ +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); +} + /** The history id of the user row that shows `content`. */ function userRowId(workspaceId: string, content: string) { const row = useWorkspaceStoreRaw() @@ -1379,16 +1396,7 @@ describe("A workspace switch ends an open edit (#5808)", () => { try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; const other = await addOtherWorkspace(app, "switch-edit-keeps-other-edit-other"); - // Visit B first: it keeps cached rows, so ChatPane stays mounted across the switches. - 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); + await visitWithMessage(app, other); const textarea = await startEditWithUnsentDraft(app, scope); const editId = userRowId(app.workspaceId, "first message"); @@ -1555,7 +1563,16 @@ describe("A workspace switch ends an open edit (#5808)", () => { }, LOAD_TOLERANT_WAIT); expect(occurrences(sent, "first row note")).toBe(1); await app.chat.expectStreamComplete(); - await waitFor(() => expect(notesShowing(app, "first row note")).toBe(0), LOAD_TOLERANT_WAIT); + // Read the store, not the panel: the panel hides while the send is in flight. + await waitFor( + () => + expect( + getReviewStateStore() + .getAttachedReviews(app.workspaceId) + .filter((attached) => attached.data.userNote === "first row note") + ).toHaveLength(0), + LOAD_TOLERANT_WAIT + ); sendSpy.mockRestore(); } finally { await app.dispose(); @@ -1653,9 +1670,8 @@ describe("A workspace switch ends an open edit (#5808)", () => { try { const scope: DraftScope = { kind: "workspace", workspaceId: app.workspaceId }; const other = await addOtherWorkspace(app, "switch-edit-row-windowed-other"); - // Visit B first: ChatPane then stays mounted across the switches, and only the switch - // itself ends the edit. - await switchAwayAndBack(app, other); + // ChatPane stays mounted across the switches: the switch itself must end the edit. + await visitWithMessage(app, other); const textarea = await startEditWithUnsentDraft(app, scope); const editId = userRowId(app.workspaceId, "first message"); typeIntoEdit(textarea, "edited before switch"); From c7e7fdbe760b9780d6652b20e985b985acc40656 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 8 Oct 2026 07:20:16 +0000 Subject: [PATCH 15/15] docs: the per-commit edit release effect stays O(1) with no I/O It runs on every ChatInputInner commit, stream updates included. --- src/browser/features/ChatInput/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 79efa10d70..f8ecbecef4 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1340,6 +1340,9 @@ const ChatInputInner: React.FC = (props) => { if (session) session.sendInFlight = inFlight; }; // After every commit: the edit's end arrives as a prop change, and settled sessions no-op. + // PERF: this runs on every ChatInputInner commit, stream updates included. Keep it O(1) with + // no I/O: releaseEndedEdit reads only refs and returns before any store read when no edit + // session exists (or it is settled, in flight, or still open). useEffect(() => { releaseEndedEditRef.current = releaseEndedEdit; releaseEndedEdit();