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(
407407 isEnvironmentUnavailable: boolean;
408408 hasSendableContent: boolean;
409409 preserveComposerFocusOnPointerDown?: boolean;
410 showSendWhileRunning?: boolean;
410411 onPreviousPendingQuestion: () => void;
411412 onInterrupt: () => void;
412413 onImplementPlanInNewThread: () => void;
......@@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
435436 isPreparingWorktree={props.isPreparingWorktree}
436437 hasSendableContent={props.hasSendableContent}
437438 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
439 showSendWhileRunning={props.showSendWhileRunning ?? false}
438440 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
439441 onInterrupt={props.onInterrupt}
440442 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
......@@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31663168 isPreparingWorktree={isPreparingWorktree}
31673169 hasSendableContent={composerSendState.hasSendableContent}
31683170 preserveComposerFocusOnPointerDown={isMobileViewport}
3171 showSendWhileRunning={isMobileViewport}
31693172 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
31703173 onInterrupt={handleInterruptPrimaryAction}
31713174 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 {
2828 isPreparingWorktree: boolean;
2929 hasSendableContent: boolean;
3030 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;
3134 onPreviousPendingQuestion: () => void;
3235 onInterrupt: () => void;
3336 onImplementPlanInNewThread: () => void;
......@@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
6871 isPreparingWorktree,
6972 hasSendableContent,
7073 preserveComposerFocusOnPointerDown = false,
74 showSendWhileRunning = false,
7175 onPreviousPendingQuestion,
7276 onInterrupt,
7377 onImplementPlanInNewThread,
......@@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
8690 type="button"
8791 className={cn(
8892 "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",
9098 )}
9199 {...pointerFocusProps}
92100 onClick={onInterrupt}
......@@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
153161 );
154162 }
155163
156 if (isRunning) {
157 return renderStopGenerationButton(false);
158 }
159
160164 if (showPlanFollowUpPrompt) {
161165 if (promptHasText) {
162166 return (
......@@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
214218 );
215219 }
216220
217 return (
221 const sendButton = (
218222 <button
219223 type="submit"
220224 className={cn(
......@@ -265,4 +269,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
265269 )}
266270 </button>
267271 );
272
273 if (!isRunning) {
274 return sendButton;
275 }
276
277 return (
278 <>
279 {renderStopGenerationButton(false)}
280 {showSendWhileRunning && hasSendableContent ? sendButton : null}
281 </>
282 );
268283});