authorgravatar for 78485654+AMohamedAakhil@users.noreply.github.comBootesVoid <78485654+AMohamedAakhil@users.noreply.github.com> 2026-08-15 17:31:11+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:01:11+00:00
log7afa184a99b266d466cc9517c147a75c3d839ad7
tree8b9eaee79a2e997777bb73c885c4001e63316eea
parenta7c5ad5db167b3a172ccb26408b0638c99b2a459
signature Signed by PGP key B5690EEEBB952194

fix(web): keep send reachable while a turn is running on mobile (#4781)

Co-authored-by: AMohamedAakhil <hello@takaitech.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 287 insertions(+), 224 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+3
...@@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
407 isEnvironmentUnavailable: boolean;407 isEnvironmentUnavailable: boolean;
408 hasSendableContent: boolean;408 hasSendableContent: boolean;
409 preserveComposerFocusOnPointerDown?: boolean;409 preserveComposerFocusOnPointerDown?: boolean;
410 showSendWhileRunning?: boolean;
410 onPreviousPendingQuestion: () => void;411 onPreviousPendingQuestion: () => void;
411 onInterrupt: () => void;412 onInterrupt: () => void;
412 onImplementPlanInNewThread: () => void;413 onImplementPlanInNewThread: () => void;
...@@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
435 isPreparingWorktree={props.isPreparingWorktree}436 isPreparingWorktree={props.isPreparingWorktree}
436 hasSendableContent={props.hasSendableContent}437 hasSendableContent={props.hasSendableContent}
437 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}438 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
439 showSendWhileRunning={props.showSendWhileRunning ?? false}
438 onPreviousPendingQuestion={props.onPreviousPendingQuestion}440 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
439 onInterrupt={props.onInterrupt}441 onInterrupt={props.onInterrupt}
440 onImplementPlanInNewThread={props.onImplementPlanInNewThread}442 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
...@@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3166 isPreparingWorktree={isPreparingWorktree}3168 isPreparingWorktree={isPreparingWorktree}
3167 hasSendableContent={composerSendState.hasSendableContent}3169 hasSendableContent={composerSendState.hasSendableContent}
3168 preserveComposerFocusOnPointerDown={isMobileViewport}3170 preserveComposerFocusOnPointerDown={isMobileViewport}
3171 showSendWhileRunning={isMobileViewport}
3169 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}3172 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3170 onInterrupt={handleInterruptPrimaryAction}3173 onInterrupt={handleInterruptPrimaryAction}
3171 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}3174 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
apps/web/src/components/chat/ComposerPrimaryActions.test.ts deleted-218
...@@ -1,218 +0,0 @@
1import { createElement } from "react";
2import { renderToStaticMarkup } from "react-dom/server";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5const stageArtworkState = vi.hoisted(() => ({
6 mode: "none" as "artwork" | "none",
7 variant: null as "nightly" | "dev" | null,
8}));
9
10vi.mock("~/hooks/useSettings", () => ({
11 useEnvironmentIdentificationMode: () => stageArtworkState.mode,
12}));
13vi.mock("../SidebarStageBackdrop", () => ({
14 StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`,
15 useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null),
16}));
17
18import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
19
20function renderPendingActions(isRunning: boolean) {
21 return renderToStaticMarkup(
22 createElement(ComposerPrimaryActions, {
23 compact: true,
24 pendingAction: {
25 questionIndex: 0,
26 isLastQuestion: true,
27 canAdvance: true,
28 isResponding: false,
29 isComplete: true,
30 },
31 isRunning,
32 showPlanFollowUpPrompt: false,
33 promptHasText: false,
34 isSendBusy: false,
35 sendDisabledReason: null,
36 isConnecting: false,
37 isEnvironmentUnavailable: false,
38 isPreparingWorktree: false,
39 hasSendableContent: false,
40 onPreviousPendingQuestion: () => {},
41 onInterrupt: () => {},
42 onImplementPlanInNewThread: () => {},
43 }),
44 );
45}
46
47function renderStandaloneStop() {
48 return renderToStaticMarkup(
49 createElement(ComposerPrimaryActions, {
50 compact: true,
51 pendingAction: null,
52 isRunning: true,
53 showPlanFollowUpPrompt: false,
54 promptHasText: false,
55 isSendBusy: false,
56 sendDisabledReason: null,
57 isConnecting: false,
58 isEnvironmentUnavailable: false,
59 isPreparingWorktree: false,
60 hasSendableContent: false,
61 onPreviousPendingQuestion: () => {},
62 onInterrupt: () => {},
63 onImplementPlanInNewThread: () => {},
64 }),
65 );
66}
67
68function renderSendButton() {
69 return renderToStaticMarkup(
70 createElement(ComposerPrimaryActions, {
71 compact: true,
72 pendingAction: null,
73 isRunning: false,
74 showPlanFollowUpPrompt: false,
75 promptHasText: true,
76 isSendBusy: false,
77 sendDisabledReason: null,
78 isConnecting: false,
79 isEnvironmentUnavailable: false,
80 isPreparingWorktree: false,
81 hasSendableContent: true,
82 onPreviousPendingQuestion: () => {},
83 onInterrupt: () => {},
84 onImplementPlanInNewThread: () => {},
85 }),
86 );
87}
88
89afterEach(() => {
90 stageArtworkState.mode = "none";
91 stageArtworkState.variant = null;
92});
93
94describe("formatPendingPrimaryActionLabel", () => {
95 it("returns 'Submitting...' while responding", () => {
96 expect(
97 formatPendingPrimaryActionLabel({
98 compact: false,
99 isLastQuestion: false,
100 isResponding: true,
101 questionIndex: 0,
102 }),
103 ).toBe("Submitting...");
104 });
105
106 it("returns 'Submitting...' while responding regardless of other flags", () => {
107 expect(
108 formatPendingPrimaryActionLabel({
109 compact: true,
110 isLastQuestion: true,
111 isResponding: true,
112 questionIndex: 3,
113 }),
114 ).toBe("Submitting...");
115 });
116
117 it("returns 'Submit' in compact mode on the last question", () => {
118 expect(
119 formatPendingPrimaryActionLabel({
120 compact: true,
121 isLastQuestion: true,
122 isResponding: false,
123 questionIndex: 0,
124 }),
125 ).toBe("Submit");
126 });
127
128 it("returns 'Next' in compact mode when not the last question", () => {
129 expect(
130 formatPendingPrimaryActionLabel({
131 compact: true,
132 isLastQuestion: false,
133 isResponding: false,
134 questionIndex: 1,
135 }),
136 ).toBe("Next");
137 });
138
139 it("returns 'Next question' when not the last question", () => {
140 expect(
141 formatPendingPrimaryActionLabel({
142 compact: false,
143 isLastQuestion: false,
144 isResponding: false,
145 questionIndex: 0,
146 }),
147 ).toBe("Next question");
148 });
149
150 it("returns singular 'Submit answer' on the last question when it is the only question", () => {
151 expect(
152 formatPendingPrimaryActionLabel({
153 compact: false,
154 isLastQuestion: true,
155 isResponding: false,
156 questionIndex: 0,
157 }),
158 ).toBe("Submit answer");
159 });
160
161 it("returns plural 'Submit answers' on the last question when there are multiple questions", () => {
162 expect(
163 formatPendingPrimaryActionLabel({
164 compact: false,
165 isLastQuestion: true,
166 isResponding: false,
167 questionIndex: 1,
168 }),
169 ).toBe("Submit answers");
170 });
171
172 it("returns plural 'Submit answers' for higher question indices", () => {
173 expect(
174 formatPendingPrimaryActionLabel({
175 compact: false,
176 isLastQuestion: true,
177 isResponding: false,
178 questionIndex: 5,
179 }),
180 ).toBe("Submit answers");
181 });
182});
183
184describe("ComposerPrimaryActions", () => {
185 it("offers Stop generation while a running turn is waiting for user input", () => {
186 expect(renderPendingActions(true)).toContain('aria-label="Stop generation"');
187 });
188
189 it("does not offer Stop generation for a pending request without a running turn", () => {
190 expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"');
191 });
192
193 it("matches the small pending action size without changing the standalone size", () => {
194 expect(renderPendingActions(true)).toContain("size-8 sm:size-7");
195 expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");
196 expect(renderStandaloneStop()).not.toContain("sm:size-7");
197 });
198
199 it("renders stage artwork inside the send button when artwork identification is active", () => {
200 stageArtworkState.mode = "artwork";
201 stageArtworkState.variant = "nightly";
202
203 const markup = renderSendButton();
204
205 expect(markup).toContain("stage-nightly");
206 expect(markup).toContain("bg-transparent text-white");
207 expect(markup).not.toContain("bg-message-action text-message-action-foreground");
208 });
209
210 it("keeps the normal send-button fill when artwork identification is inactive", () => {
211 stageArtworkState.variant = "nightly";
212
213 const markup = renderSendButton();
214
215 expect(markup).not.toContain("stage-nightly");
216 expect(markup).toContain("bg-message-action text-message-action-foreground");
217 });
218});
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx created+263
...@@ -0,0 +1,263 @@
1import { createElement } from "react";
2import { renderToStaticMarkup } from "react-dom/server";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5const stageArtworkState = vi.hoisted(() => ({
6 mode: "none" as "artwork" | "none",
7 variant: null as "nightly" | "dev" | null,
8}));
9
10vi.mock("~/hooks/useSettings", () => ({
11 useEnvironmentIdentificationMode: () => stageArtworkState.mode,
12}));
13vi.mock("../SidebarStageBackdrop", () => ({
14 StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`,
15 useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null),
16}));
17
18import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
19
20function renderPendingActions(isRunning: boolean) {
21 return renderToStaticMarkup(
22 createElement(ComposerPrimaryActions, {
23 compact: true,
24 pendingAction: {
25 questionIndex: 0,
26 isLastQuestion: true,
27 canAdvance: true,
28 isResponding: false,
29 isComplete: true,
30 },
31 isRunning,
32 showPlanFollowUpPrompt: false,
33 promptHasText: false,
34 isSendBusy: false,
35 sendDisabledReason: null,
36 isConnecting: false,
37 isEnvironmentUnavailable: false,
38 isPreparingWorktree: false,
39 hasSendableContent: false,
40 onPreviousPendingQuestion: () => {},
41 onInterrupt: () => {},
42 onImplementPlanInNewThread: () => {},
43 }),
44 );
45}
46
47function renderStandaloneStop() {
48 return renderToStaticMarkup(
49 createElement(ComposerPrimaryActions, {
50 compact: true,
51 pendingAction: null,
52 isRunning: true,
53 showPlanFollowUpPrompt: false,
54 promptHasText: false,
55 isSendBusy: false,
56 sendDisabledReason: null,
57 isConnecting: false,
58 isEnvironmentUnavailable: false,
59 isPreparingWorktree: false,
60 hasSendableContent: false,
61 onPreviousPendingQuestion: () => {},
62 onInterrupt: () => {},
63 onImplementPlanInNewThread: () => {},
64 }),
65 );
66}
67
68function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) {
69 return renderToStaticMarkup(
70 createElement(ComposerPrimaryActions, {
71 compact: true,
72 pendingAction: null,
73 isRunning: true,
74 showPlanFollowUpPrompt: false,
75 promptHasText: hasSendableContent,
76 isSendBusy: false,
77 sendDisabledReason: null,
78 isConnecting: false,
79 isEnvironmentUnavailable: false,
80 isPreparingWorktree: false,
81 hasSendableContent,
82 showSendWhileRunning,
83 onPreviousPendingQuestion: () => {},
84 onInterrupt: () => {},
85 onImplementPlanInNewThread: () => {},
86 }),
87 );
88}
89
90function renderSendButton() {
91 return renderToStaticMarkup(
92 createElement(ComposerPrimaryActions, {
93 compact: true,
94 pendingAction: null,
95 isRunning: false,
96 showPlanFollowUpPrompt: false,
97 promptHasText: true,
98 isSendBusy: false,
99 sendDisabledReason: null,
100 isConnecting: false,
101 isEnvironmentUnavailable: false,
102 isPreparingWorktree: false,
103 hasSendableContent: true,
104 onPreviousPendingQuestion: () => {},
105 onInterrupt: () => {},
106 onImplementPlanInNewThread: () => {},
107 }),
108 );
109}
110
111afterEach(() => {
112 stageArtworkState.mode = "none";
113 stageArtworkState.variant = null;
114});
115
116describe("formatPendingPrimaryActionLabel", () => {
117 it("returns 'Submitting...' while responding", () => {
118 expect(
119 formatPendingPrimaryActionLabel({
120 compact: false,
121 isLastQuestion: false,
122 isResponding: true,
123 questionIndex: 0,
124 }),
125 ).toBe("Submitting...");
126 });
127
128 it("returns 'Submitting...' while responding regardless of other flags", () => {
129 expect(
130 formatPendingPrimaryActionLabel({
131 compact: true,
132 isLastQuestion: true,
133 isResponding: true,
134 questionIndex: 3,
135 }),
136 ).toBe("Submitting...");
137 });
138
139 it("returns 'Submit' in compact mode on the last question", () => {
140 expect(
141 formatPendingPrimaryActionLabel({
142 compact: true,
143 isLastQuestion: true,
144 isResponding: false,
145 questionIndex: 0,
146 }),
147 ).toBe("Submit");
148 });
149
150 it("returns 'Next' in compact mode when not the last question", () => {
151 expect(
152 formatPendingPrimaryActionLabel({
153 compact: true,
154 isLastQuestion: false,
155 isResponding: false,
156 questionIndex: 1,
157 }),
158 ).toBe("Next");
159 });
160
161 it("returns 'Next question' when not the last question", () => {
162 expect(
163 formatPendingPrimaryActionLabel({
164 compact: false,
165 isLastQuestion: false,
166 isResponding: false,
167 questionIndex: 0,
168 }),
169 ).toBe("Next question");
170 });
171
172 it("returns singular 'Submit answer' on the last question when it is the only question", () => {
173 expect(
174 formatPendingPrimaryActionLabel({
175 compact: false,
176 isLastQuestion: true,
177 isResponding: false,
178 questionIndex: 0,
179 }),
180 ).toBe("Submit answer");
181 });
182
183 it("returns plural 'Submit answers' on the last question when there are multiple questions", () => {
184 expect(
185 formatPendingPrimaryActionLabel({
186 compact: false,
187 isLastQuestion: true,
188 isResponding: false,
189 questionIndex: 1,
190 }),
191 ).toBe("Submit answers");
192 });
193
194 it("returns plural 'Submit answers' for higher question indices", () => {
195 expect(
196 formatPendingPrimaryActionLabel({
197 compact: false,
198 isLastQuestion: true,
199 isResponding: false,
200 questionIndex: 5,
201 }),
202 ).toBe("Submit answers");
203 });
204});
205
206describe("ComposerPrimaryActions", () => {
207 it("offers Stop generation while a running turn is waiting for user input", () => {
208 expect(renderPendingActions(true)).toContain('aria-label="Stop generation"');
209 });
210
211 it("does not offer Stop generation for a pending request without a running turn", () => {
212 expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"');
213 });
214
215 it("matches the small pending action size without changing the standalone size", () => {
216 expect(renderPendingActions(true)).toContain("size-8 sm:size-7");
217 expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");
218 expect(renderStandaloneStop()).not.toContain("sm:size-7");
219 });
220
221 it("renders stage artwork inside the send button when artwork identification is active", () => {
222 stageArtworkState.mode = "artwork";
223 stageArtworkState.variant = "nightly";
224
225 const markup = renderSendButton();
226
227 expect(markup).toContain("stage-nightly");
228 expect(markup).toContain("bg-transparent text-white");
229 expect(markup).not.toContain("bg-message-action text-message-action-foreground");
230 });
231
232 it("keeps the normal send-button fill when artwork identification is inactive", () => {
233 stageArtworkState.variant = "nightly";
234
235 const markup = renderSendButton();
236
237 expect(markup).not.toContain("stage-nightly");
238 expect(markup).toContain("bg-message-action text-message-action-foreground");
239 });
240
241 it("only renders stop while running when Enter-to-send is available", () => {
242 const markup = renderRunningActions(false, true);
243
244 expect(markup).toContain('aria-label="Stop generation"');
245 expect(markup).not.toContain('aria-label="Send message"');
246 });
247
248 it("renders send alongside stop while running when Enter-to-send is unavailable", () => {
249 const markup = renderRunningActions(true, true);
250
251 expect(markup).toContain('aria-label="Stop generation"');
252 expect(markup).toContain('aria-label="Send message"');
253 expect(markup).toContain('type="submit"');
254 expect(markup).toContain("size-9 sm:size-8");
255 });
256
257 it("keeps stop as the only action while running with an empty composer", () => {
258 const markup = renderRunningActions(true, false);
259
260 expect(markup).toContain('aria-label="Stop generation"');
261 expect(markup).not.toContain('aria-label="Send message"');
262 });
263});
apps/web/src/components/chat/ComposerPrimaryActions.tsx+21-6
...@@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps {...@@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps {
28 isPreparingWorktree: boolean;28 isPreparingWorktree: boolean;
29 hasSendableContent: boolean;29 hasSendableContent: boolean;
30 preserveComposerFocusOnPointerDown?: boolean;30 preserveComposerFocusOnPointerDown?: boolean;
31 /** Enter-to-send is disabled on mobile viewports, where stop would otherwise
32 * be the only primary action and a running turn could not be steered. */
33 showSendWhileRunning?: boolean;
31 onPreviousPendingQuestion: () => void;34 onPreviousPendingQuestion: () => void;
32 onInterrupt: () => void;35 onInterrupt: () => void;
33 onImplementPlanInNewThread: () => void;36 onImplementPlanInNewThread: () => void;
...@@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
68 isPreparingWorktree,71 isPreparingWorktree,
69 hasSendableContent,72 hasSendableContent,
70 preserveComposerFocusOnPointerDown = false,73 preserveComposerFocusOnPointerDown = false,
74 showSendWhileRunning = false,
71 onPreviousPendingQuestion,75 onPreviousPendingQuestion,
72 onInterrupt,76 onInterrupt,
73 onImplementPlanInNewThread,77 onImplementPlanInNewThread,
...@@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
86 type="button"90 type="button"
87 className={cn(91 className={cn(
88 "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none",92 "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none",
89 insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8",93 insidePendingAction
94 ? "size-8 sm:size-7"
95 : showSendWhileRunning && hasSendableContent
96 ? "size-9 sm:size-8"
97 : "size-8 sm:h-8 sm:w-8",
90 )}98 )}
91 {...pointerFocusProps}99 {...pointerFocusProps}
92 onClick={onInterrupt}100 onClick={onInterrupt}
...@@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
153 );161 );
154 }162 }
155163
156 if (isRunning) {
157 return renderStopGenerationButton(false);
158 }
159
160 if (showPlanFollowUpPrompt) {164 if (showPlanFollowUpPrompt) {
161 if (promptHasText) {165 if (promptHasText) {
162 return (166 return (
...@@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
214 );218 );
215 }219 }
216220
217 return (221 const sendButton = (
218 <button222 <button
219 type="submit"223 type="submit"
220 className={cn(224 className={cn(
...@@ -265,4 +269,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -265,4 +269,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
265 )}269 )}
266 </button>270 </button>
267 );271 );
272
273 if (!isRunning) {
274 return sendButton;
275 }
276
277 return (
278 <>
279 {renderStopGenerationButton(false)}
280 {showSendWhileRunning && hasSendableContent ? sendButton : null}
281 </>
282 );
268});283});