From e86fa85a120597c67dc4fce35efeccb570296068 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:33:49 -0500 Subject: [PATCH 1/2] fix(web): a failed plan follow-up no longer overwrites the next draft The plan follow-up path cleared the composer, awaited the send, and on failure wrote the old feedback back unconditionally. A draft typed while the request was in flight was replaced, and after navigating to another thread the old prompt landed in that thread's composer refs and cursor. Restore only into an empty draft and touch the live composer only while the follow-up's route is current, as a failed normal send already does. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 54 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 10 +++- .../src/components/chat/useComposerSend.ts | 10 +++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 426818dba7a24..f5b9baec43c96 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,6 +3,7 @@ import { findRecordedWorktreeSetup, mergeDirectPreviewAnnotation, planComposerTurn, + restorePlanFollowUpComposer, snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, @@ -1071,6 +1072,59 @@ describe("snapshotComposerSend", () => { }); }); +describe("restorePlanFollowUpComposer", () => { + // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on + // screen. + it.each([ + { + name: "puts the follow-up back while its thread is open", + before: { draft: "", refs: "", cursor: null }, + routeIsCurrent: true, + after: { draft: "Tighten step 2", refs: "Tighten step 2", cursor: "Tighten step 2" }, + }, + { + name: "keeps a draft typed while the follow-up was in flight", + before: { draft: "Next idea", refs: "Next idea", cursor: null }, + routeIsCurrent: true, + after: { draft: "Next idea", refs: "Next idea", cursor: null }, + }, + { + name: "leaves another thread's composer alone after navigating away", + before: { draft: "", refs: "Other thread", cursor: null }, + routeIsCurrent: false, + after: { draft: "Tighten step 2", refs: "Other thread", cursor: null }, + }, + ])("$name", ({ before, routeIsCurrent, after }) => { + const composer: { draft: string; refs: string; cursor: string | null } = { ...before }; + restorePlanFollowUpComposer({ + snapshot: { + prompt: "Tighten step 2", + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + threadContexts: [], + }, + draftHasUserContent: composer.draft.trim().length > 0, + routeIsCurrent, + writeComposerRefs: (snapshot) => { + composer.refs = snapshot.prompt; + }, + writePrompt: (prompt) => { + composer.draft = prompt; + }, + writeTerminalContexts: () => undefined, + writeReviewComments: () => undefined, + writePreviewAnnotations: () => undefined, + writeThreadContexts: () => undefined, + resetCursor: ({ prompt }) => { + composer.cursor = prompt; + }, + }); + + expect(composer).toEqual(after); + }); +}); + describe("planComposerTurn", () => { const base = { messageText: "hello", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0ce51ee5e5534..229a7ccbba879 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1304,10 +1304,15 @@ export interface PlanFollowUpComposerSnapshot { /** * The caller clears the composer before awaiting the send, so every field it * held has to be written back here: a dropped field silently discards user - * context. + * context. A draft the user started while the send was in flight is newer and + * stays. The composer refs and cursor belong to the thread on screen, so they + * follow only while the follow-up's route is still current. */ export function restorePlanFollowUpComposer(input: { readonly snapshot: PlanFollowUpComposerSnapshot; + readonly draftHasUserContent: boolean; + readonly routeIsCurrent: boolean; + readonly writeComposerRefs: (snapshot: PlanFollowUpComposerSnapshot) => void; readonly writePrompt: (prompt: string) => void; readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; readonly writeReviewComments: (comments: ReadonlyArray) => void; @@ -1319,11 +1324,14 @@ export function restorePlanFollowUpComposer(input: { detectTrigger: boolean; }) => void; }): void { + if (input.draftHasUserContent) return; + if (input.routeIsCurrent) input.writeComposerRefs(input.snapshot); input.writePrompt(input.snapshot.prompt); input.writeTerminalContexts(input.snapshot.terminalContexts); input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); input.writeThreadContexts(input.snapshot.threadContexts); + if (!input.routeIsCurrent) return; input.resetCursor({ cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), prompt: input.snapshot.prompt, diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index ed7def98c88c8..65dc88a92d666 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -684,8 +684,6 @@ export function useComposerSend(input: ComposerSendInput) { interactionMode: followUp.interactionMode, }); if (!followUpSent) { - promptRef.current = followUpPromptSnapshot; - composerTerminalContextsRef.current = [...followUpTerminalContexts]; restorePlanFollowUpComposer({ snapshot: { prompt: followUpPromptSnapshot, @@ -694,6 +692,14 @@ export function useComposerSend(input: ComposerSendInput) { previewAnnotations: followUpPreviewAnnotations, threadContexts: followUpThreadContexts, }, + draftHasUserContent: composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ), + routeIsCurrent: currentRouteThreadKeyRef.current === routeThreadKey, + writeComposerRefs: (snapshot) => { + promptRef.current = snapshot.prompt; + composerTerminalContextsRef.current = [...snapshot.terminalContexts]; + }, writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt), writeTerminalContexts: (contexts) => setComposerDraftTerminalContexts(composerDraftTarget, [...contexts]), From 65d765a035f80c5d5cccb1fc311b947d87a766a2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 20:57:41 -0500 Subject: [PATCH 2/2] test(web): move the plan follow-up restore test next to planComposerTurn Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f5b9baec43c96..320fdecb9d757 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1072,59 +1072,6 @@ describe("snapshotComposerSend", () => { }); }); -describe("restorePlanFollowUpComposer", () => { - // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on - // screen. - it.each([ - { - name: "puts the follow-up back while its thread is open", - before: { draft: "", refs: "", cursor: null }, - routeIsCurrent: true, - after: { draft: "Tighten step 2", refs: "Tighten step 2", cursor: "Tighten step 2" }, - }, - { - name: "keeps a draft typed while the follow-up was in flight", - before: { draft: "Next idea", refs: "Next idea", cursor: null }, - routeIsCurrent: true, - after: { draft: "Next idea", refs: "Next idea", cursor: null }, - }, - { - name: "leaves another thread's composer alone after navigating away", - before: { draft: "", refs: "Other thread", cursor: null }, - routeIsCurrent: false, - after: { draft: "Tighten step 2", refs: "Other thread", cursor: null }, - }, - ])("$name", ({ before, routeIsCurrent, after }) => { - const composer: { draft: string; refs: string; cursor: string | null } = { ...before }; - restorePlanFollowUpComposer({ - snapshot: { - prompt: "Tighten step 2", - terminalContexts: [], - reviewComments: [], - previewAnnotations: [], - threadContexts: [], - }, - draftHasUserContent: composer.draft.trim().length > 0, - routeIsCurrent, - writeComposerRefs: (snapshot) => { - composer.refs = snapshot.prompt; - }, - writePrompt: (prompt) => { - composer.draft = prompt; - }, - writeTerminalContexts: () => undefined, - writeReviewComments: () => undefined, - writePreviewAnnotations: () => undefined, - writeThreadContexts: () => undefined, - resetCursor: ({ prompt }) => { - composer.cursor = prompt; - }, - }); - - expect(composer).toEqual(after); - }); -}); - describe("planComposerTurn", () => { const base = { messageText: "hello", @@ -1190,6 +1137,59 @@ describe("planComposerTurn", () => { }); }); +describe("restorePlanFollowUpComposer", () => { + // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on + // screen. + it.each([ + { + name: "puts the follow-up back while its thread is open", + before: { draft: "", refs: "", cursor: null }, + routeIsCurrent: true, + after: { draft: "Tighten step 2", refs: "Tighten step 2", cursor: "Tighten step 2" }, + }, + { + name: "keeps a draft typed while the follow-up was in flight", + before: { draft: "Next idea", refs: "Next idea", cursor: null }, + routeIsCurrent: true, + after: { draft: "Next idea", refs: "Next idea", cursor: null }, + }, + { + name: "leaves another thread's composer alone after navigating away", + before: { draft: "", refs: "Other thread", cursor: null }, + routeIsCurrent: false, + after: { draft: "Tighten step 2", refs: "Other thread", cursor: null }, + }, + ])("$name", ({ before, routeIsCurrent, after }) => { + const composer: { draft: string; refs: string; cursor: string | null } = { ...before }; + restorePlanFollowUpComposer({ + snapshot: { + prompt: "Tighten step 2", + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + threadContexts: [], + }, + draftHasUserContent: composer.draft.trim().length > 0, + routeIsCurrent, + writeComposerRefs: (snapshot) => { + composer.refs = snapshot.prompt; + }, + writePrompt: (prompt) => { + composer.draft = prompt; + }, + writeTerminalContexts: () => undefined, + writeReviewComments: () => undefined, + writePreviewAnnotations: () => undefined, + writeThreadContexts: () => undefined, + resetCursor: ({ prompt }) => { + composer.cursor = prompt; + }, + }); + + expect(composer).toEqual(after); + }); +}); + describe("withMessageContext", () => { const context = { records: [] } as unknown as NonNullable< Parameters[0]["context"]