From 627d963670035b5595665f69b279d9b9c9d72ada Mon Sep 17 00:00:00 2001 From: AMohamedAakhil Date: Tue, 28 Jul 2026 22:24:34 +0200 Subject: [PATCH] fix(web): keep send reachable while a turn is running on mobile On viewports below `sm` there is no way to send a follow-up while a turn is running. `ComposerPrimaryActions` replaces the submit button with the stop button whenever `isRunning` is set, and `shouldSubmitComposerOnEnter` returns false on mobile viewports, so the keyboard path that desktop users rely on does not exist there. Sending mid-run is already supported: `onSend` has no `isRunning` guard and `isSendBusy` tracks the local dispatch rather than the turn state. Desktop reaches it with Enter. This only makes the same action reachable by touch. Adds `showSendWhileRunning`, wired to `isMobileViewport`, which renders the send button next to stop while a turn runs and the composer has content. Desktop rendering is unchanged. The running branch moves below the send button so both can be rendered without duplicating markup. That reorders it past the plan follow-up branch, which is inert: `showPlanFollowUpPrompt` requires `latestTurnSettled`, so it cannot be set while a turn is running. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 3 + ...est.ts => ComposerPrimaryActions.test.tsx} | 57 ++++++++++++++++++- .../chat/ComposerPrimaryActions.tsx | 43 ++++++++------ 3 files changed, 85 insertions(+), 18 deletions(-) rename apps/web/src/components/chat/{ComposerPrimaryActions.test.ts => ComposerPrimaryActions.test.tsx} (55%) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fa9a75dd323..3b2cf8ab376 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -426,6 +426,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -453,6 +454,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -3172,6 +3174,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx similarity index 55% rename from apps/web/src/components/chat/ComposerPrimaryActions.test.ts rename to apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index b7624db0a8f..14ee2e092d5 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -1,6 +1,29 @@ +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; + +const renderPrimaryActions = ( + overrides: Partial[0]> = {}, +) => + renderToStaticMarkup( + {}} + onInterrupt={() => {}} + onImplementPlanInNewThread={() => {}} + {...overrides} + />, + ); describe("formatPendingPrimaryActionLabel", () => { it("returns 'Submitting...' while responding", () => { @@ -91,3 +114,35 @@ describe("formatPendingPrimaryActionLabel", () => { ).toBe("Submit answers"); }); }); + +describe("ComposerPrimaryActions send-while-running", () => { + it("only renders stop while running when Enter-to-send is available", () => { + const markup = renderPrimaryActions({ isRunning: true, hasSendableContent: true }); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("renders send alongside stop while running when Enter-to-send is unavailable", () => { + const markup = renderPrimaryActions({ + isRunning: true, + hasSendableContent: true, + showSendWhileRunning: true, + }); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('type="submit"'); + }); + + it("keeps stop as the only action while running with an empty composer", () => { + const markup = renderPrimaryActions({ + isRunning: true, + hasSendableContent: false, + showSendWhileRunning: true, + }); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 19c20e2abf2..3b76e4dd4ea 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -27,6 +27,9 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + /** Enter-to-send is disabled on mobile viewports, where stop would otherwise + * be the only primary action and a running turn could not be steered. */ + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -66,6 +69,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -129,22 +133,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { - return ( - - ); - } - if (showPlanFollowUpPrompt) { if (promptHasText) { return ( @@ -199,7 +187,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - return ( + const sendButton = ( + {showSendWhileRunning && hasSendableContent ? sendButton : null} + + ); });