From 4a2d99c44fc8856d5920cfac80e5520d0b827717 Mon Sep 17 00:00:00 2001 From: Isaac-b-ux <264455269+Isaac-b-ux@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:05:46 -0500 Subject: [PATCH 1/2] fix(web): a send that finishes after you leave its thread no longer blanks the open thread's composer Sending to an existing thread waits for attachment uploads before it clears the composer. If the user switches to another existing thread meanwhile, the same ChatView instance is showing it, and the send's final cleanup reset the shared prompt ref and the cursor of whichever composer was mounted. The open thread then kept its draft text on screen but sent an empty prompt until the next keystroke. Clear the sent thread's draft as before, but touch the prompt ref and cursor only while the sent thread is still the routed one, the same guard the failed-send restore path already uses. Co-Authored-By: Claude Sonnet 5.5 --- .../web/src/components/ChatView.logic.test.ts | 35 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 14 ++++++++ .../src/components/chat/useComposerSend.ts | 12 +++++-- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 81b6992a59d3b..65f5c53d4a231 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2,6 +2,7 @@ import { boundAttachmentIds, buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, + clearSentComposer, planComposerTurn, restorePlanFollowUpComposer, snapshotComposerSend, @@ -1129,6 +1130,40 @@ describe("planComposerTurn", () => { }); }); +describe("clearSentComposer", () => { + // The draft is the sent thread's; the prompt ref and cursor are whatever thread is on screen. + it.each([ + { + name: "clears the composer while the sent thread is open", + routeIsCurrent: true, + before: { draft: "Ship it", promptRef: "Ship it", cursor: "Ship it" }, + after: { draft: "", promptRef: "", cursor: null }, + }, + { + name: "leaves the open thread's prompt and cursor alone after navigating away", + routeIsCurrent: false, + before: { draft: "Ship it", promptRef: "Other thread", cursor: "Other thread" }, + after: { draft: "", promptRef: "Other thread", cursor: "Other thread" }, + }, + ])("$name", ({ routeIsCurrent, before, after }) => { + const composer: { draft: string; promptRef: string; cursor: string | null } = { ...before }; + clearSentComposer({ + routeIsCurrent, + clearDraft: () => { + composer.draft = ""; + }, + clearComposerRefs: () => { + composer.promptRef = ""; + }, + resetCursor: () => { + composer.cursor = null; + }, + }); + + expect(composer).toEqual(after); + }); +}); + describe("restorePlanFollowUpComposer", () => { // The draft is the failed follow-up's thread; the refs and cursor are whatever thread is on // screen. diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4e17fdebf7b52..c6f3650ecd5b1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1215,6 +1215,20 @@ export function restorePlanFollowUpComposer(input: { }); } +// The sent draft belongs to the thread that sent it. Composer refs and the cursor belong to the +// thread on screen, so a send that finishes after the user left its thread leaves them alone. +export function clearSentComposer(input: { + readonly routeIsCurrent: boolean; + readonly clearDraft: () => void; + readonly clearComposerRefs: () => void; + readonly resetCursor: () => void; +}): void { + input.clearDraft(); + if (!input.routeIsCurrent) return; + input.clearComposerRefs(); + input.resetCursor(); +} + /** Includes the annotation screenshot only within the attachment cap; exceeding it rejects the whole turn. */ export function mergeDirectPreviewAnnotation(input: { readonly images: ReadonlyArray; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 98084e9795443..f23e0105d17f4 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -2,6 +2,7 @@ import { mergeDirectPreviewAnnotation, resolveBackgroundDraftWorkspaceOptions, shouldDockDraftHeroForSubmission, + clearSentComposer, restorePlanFollowUpComposer, buildExpiredTerminalContextToastCopy, deriveComposerSendState, @@ -1241,9 +1242,14 @@ export function useComposerSend(input: ComposerSendInput) { : null; // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + clearSentComposer({ + routeIsCurrent: currentRouteThreadKeyRef.current === routeThreadKey, + clearDraft: () => clearComposerDraftContent(composerDraftTarget), + clearComposerRefs: () => { + promptRef.current = ""; + }, + resetCursor: () => composerRef.current?.resetCursorState(), + }); const title = deriveThreadTitleSeed({ text: messageTextForSend, From 067b4f25ef3bc411250d59ae729c11c57a47909e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 00:30:05 +0000 Subject: [PATCH 2/2] refactor(web): inline the sent-composer route guard Drops the clearSentComposer helper and its callback-mirror test; the guard is two lines at the call site. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 35 ------------------- apps/web/src/components/ChatView.logic.ts | 14 -------- .../src/components/chat/useComposerSend.ts | 15 ++++---- 3 files changed, 6 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f5d2b37063621..6cf3ce762d434 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,7 +3,6 @@ import { boundAttachmentIds, buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, - clearSentComposer, planComposerTurn, deriveTimelineWorking, snapshotComposerSend, @@ -1320,40 +1319,6 @@ describe("planComposerTurn", () => { }); }); -describe("clearSentComposer", () => { - // The draft is the sent thread's; the prompt ref and cursor are whatever thread is on screen. - it.each([ - { - name: "clears the composer while the sent thread is open", - routeIsCurrent: true, - before: { draft: "Ship it", promptRef: "Ship it", cursor: "Ship it" }, - after: { draft: "", promptRef: "", cursor: null }, - }, - { - name: "leaves the open thread's prompt and cursor alone after navigating away", - routeIsCurrent: false, - before: { draft: "Ship it", promptRef: "Other thread", cursor: "Other thread" }, - after: { draft: "", promptRef: "Other thread", cursor: "Other thread" }, - }, - ])("$name", ({ routeIsCurrent, before, after }) => { - const composer: { draft: string; promptRef: string; cursor: string | null } = { ...before }; - clearSentComposer({ - routeIsCurrent, - clearDraft: () => { - composer.draft = ""; - }, - clearComposerRefs: () => { - composer.promptRef = ""; - }, - resetCursor: () => { - composer.cursor = null; - }, - }); - - expect(composer).toEqual(after); - }); -}); - describe("withMessageContext", () => { const context = { records: [] } as unknown as NonNullable< Parameters[0]["context"] diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 92f78748c1130..927a253ead2b8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1119,20 +1119,6 @@ export function shouldRefocusComposerOnWindowFocus( ); } -// The sent draft belongs to the thread that sent it. Composer refs and the cursor belong to the -// thread on screen, so a send that finishes after the user left its thread leaves them alone. -export function clearSentComposer(input: { - readonly routeIsCurrent: boolean; - readonly clearDraft: () => void; - readonly clearComposerRefs: () => void; - readonly resetCursor: () => void; -}): void { - input.clearDraft(); - if (!input.routeIsCurrent) return; - input.clearComposerRefs(); - input.resetCursor(); -} - /** Includes the annotation screenshot only within the attachment cap; exceeding it rejects the whole turn. */ export function mergeDirectPreviewAnnotation(input: { readonly images: ReadonlyArray; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 5f74bb9989695..79e1c35721c86 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -4,7 +4,6 @@ import { mergeDirectPreviewAnnotation, resolveBackgroundDraftWorkspaceOptions, shouldDockDraftHeroForSubmission, - clearSentComposer, buildExpiredTerminalContextToastCopy, deriveComposerSendState, boundAttachmentIds, @@ -1172,14 +1171,12 @@ export function useComposerSend(input: ComposerSendInput) { : null; // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); - clearSentComposer({ - routeIsCurrent: currentRouteThreadKeyRef.current === routeThreadKey, - clearDraft: () => clearComposerDraftContent(composerDraftTarget), - clearComposerRefs: () => { - promptRef.current = ""; - }, - resetCursor: () => composerRef.current?.resetCursorState(), - }); + clearComposerDraftContent(composerDraftTarget); + // The prompt ref and cursor belong to the thread on screen, which may no longer be this one. + if (currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = ""; + composerRef.current?.resetCursorState(); + } const title = deriveThreadTitleSeed({ text: messageTextForSend,