From fbd6fd18aabd6391e4b3577f129763375e4cbc31 Mon Sep 17 00:00:00 2001 From: Toni Angerer Date: Thu, 29 Oct 2026 20:15:26 +0100 Subject: [PATCH 1/3] fix(web): Enter on an empty prompt resumes an interrupted thread When the primary button showed Resume thread, Enter still went through the send path, which had nothing to send and did nothing. Enter now runs the same resume action as the button. Fixes #27045 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d7878cc29a165..736aafc126c48 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2053,6 +2053,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event?.preventDefault(); return; } + // An empty draft has nothing to send; Enter does what the Resume thread button does. + if (showResumeAction && !activePendingProgress) { + event?.preventDefault(); + if (!isSendBusy && environmentUnavailable === null) onResume(); + return; + } // A send while a pasted image is still compressing would strand that image: // the turn snapshot wouldn't include it, and it would surface in the *next* // draft instead. From 0fba1089af2f1c890242ff384a72c9cdc814dcbb Mon Sep 17 00:00:00 2001 From: Toni Angerer Date: Thu, 29 Oct 2026 21:08:05 +0100 Subject: [PATCH 2/3] fix(web): Enter waits for pending draft work before resuming An empty draft can still have context importing into it. Run the resume branch after the existing pending-work guards so Enter shows the same "still bringing attached context" notice instead of resuming early. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 736aafc126c48..00e6bbd086b30 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2053,12 +2053,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event?.preventDefault(); return; } - // An empty draft has nothing to send; Enter does what the Resume thread button does. - if (showResumeAction && !activePendingProgress) { - event?.preventDefault(); - if (!isSendBusy && environmentUnavailable === null) onResume(); - return; - } // A send while a pasted image is still compressing would strand that image: // the turn snapshot wouldn't include it, and it would surface in the *next* // draft instead. @@ -2080,6 +2074,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } + // An empty draft has nothing to send; Enter does what the Resume thread button does. + if (showResumeAction && !activePendingProgress) { + event?.preventDefault(); + if (!isSendBusy && environmentUnavailable === null) onResume(); + return; + } const submission = submitComposerDraft({ prompt: promptRef.current, submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", From 533a179bd62d03e53143088ec5435a85a84e11df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:13:37 +0000 Subject: [PATCH 3/3] fix(web): Enter-to-resume returns the submit result and gets a regression test AI-assisted; verified with the tests and tools listed above. --- .../src/components/ChatView.browser.test.tsx | 29 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 5 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 0ee0caa61d480..5535e76953a28 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -229,3 +229,32 @@ it("a rejected send leaves the timeline when a new draft was typed meanwhile", a await expect.element(page.getByText("Failed prompt")).not.toBeInTheDocument(); expect(editor.element().textContent).toContain("Next draft"); }); + +it("Enter on an empty prompt resumes an interrupted thread", async () => { + const history = threadWithHistory("interrupted", 1); + const interruptedRun = { ...history.runs[0]!, status: "interrupted" as const }; + const thread = { ...history, runs: [interruptedRun] }; + using _environment = installFakeEnvironment([thread], { acceptCommands: true }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByRole("button", { name: "Resume thread" })).toBeVisible(); + + await userEvent.click(page.getByTestId("composer-editor")); + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => + expect(dispatchedCommands()).toContainEqual( + expect.objectContaining({ manualContinuationOfRunId: interruptedRun.id }), + ), + ); +}); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b107669d4b515..c9a7ec62066c8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2146,8 +2146,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // An empty draft has nothing to send; Enter does what the Resume thread button does. if (showResumeAction && !activePendingProgress) { event?.preventDefault(); - if (!isSendBusy && environmentUnavailable === null) onResume(); - return; + if (isSendBusy || environmentUnavailable !== null) return false; + onResume(); + return true; } const submission = submitComposerDraft({ prompt: promptRef.current,