authorgravatar for hello@ipanasenko.meIllia Panasenko <hello@ipanasenko.me> 2026-08-07 23:03:52+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 17:03:52-04:00
log45d9aa90baab8f2d6b13c7ae3cf2f97128edaf7b
tree80c40003e561d24c03605673e9e400640ef97427
parent7a84f6cf10a5040d29c6392ad216d976458363cd
signature Signed by PGP key B5690EEEBB952194

fix(web): show Stop button while input is pending (#5554)


2 files changed, 95 insertions(+), 15 deletions(-)

apps/web/src/components/chat/ComposerPrimaryActions.test.ts+76-2
......@@ -1,6 +1,64 @@
1import { describe, expect, it } from "vite-plus/test";
1import { createElement } from "react";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
24
3import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
5vi.mock("~/hooks/useSettings", () => ({
6 useEnvironmentIdentificationMode: () => "none",
7}));
8vi.mock("../SidebarStageBackdrop", () => ({
9 StageBackdropButtonArt: () => null,
10 useSidebarStageBackdropVariant: () => null,
11}));
12
13import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
14
15function renderPendingActions(isRunning: boolean) {
16 return renderToStaticMarkup(
17 createElement(ComposerPrimaryActions, {
18 compact: true,
19 pendingAction: {
20 questionIndex: 0,
21 isLastQuestion: true,
22 canAdvance: true,
23 isResponding: false,
24 isComplete: true,
25 },
26 isRunning,
27 showPlanFollowUpPrompt: false,
28 promptHasText: false,
29 isSendBusy: false,
30 sendDisabledReason: null,
31 isConnecting: false,
32 isEnvironmentUnavailable: false,
33 isPreparingWorktree: false,
34 hasSendableContent: false,
35 onPreviousPendingQuestion: () => {},
36 onInterrupt: () => {},
37 onImplementPlanInNewThread: () => {},
38 }),
39 );
40}
41
42function renderStandaloneStop() {
43 return renderToStaticMarkup(
44 createElement(ComposerPrimaryActions, {
45 compact: true,
46 pendingAction: null,
47 isRunning: true,
48 showPlanFollowUpPrompt: false,
49 promptHasText: false,
50 isSendBusy: false,
51 sendDisabledReason: null,
52 isConnecting: false,
53 isEnvironmentUnavailable: false,
54 isPreparingWorktree: false,
55 hasSendableContent: false,
56 onPreviousPendingQuestion: () => {},
57 onInterrupt: () => {},
58 onImplementPlanInNewThread: () => {},
59 }),
60 );
61}
462
563describe("formatPendingPrimaryActionLabel", () => {
664 it("returns 'Submitting...' while responding", () => {
......@@ -91,3 +149,19 @@ describe("formatPendingPrimaryActionLabel", () => {
91149 ).toBe("Submit answers");
92150 });
93151});
152
153describe("ComposerPrimaryActions", () => {
154 it("offers Stop generation while a running turn is waiting for user input", () => {
155 expect(renderPendingActions(true)).toContain('aria-label="Stop generation"');
156 });
157
158 it("does not offer Stop generation for a pending request without a running turn", () => {
159 expect(renderPendingActions(false)).not.toContain('aria-label="Stop generation"');
160 });
161
162 it("matches the small pending action size without changing the standalone size", () => {
163 expect(renderPendingActions(true)).toContain("size-8 sm:size-7");
164 expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");
165 expect(renderStandaloneStop()).not.toContain("sm:size-7");
166 });
167});
apps/web/src/components/chat/ComposerPrimaryActions.tsx+19-13
......@@ -75,9 +75,27 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7575 : undefined;
7676 const isSendDisabled = sendDisabledReason !== null;
7777
78 const renderStopGenerationButton = (insidePendingAction: boolean) => (
79 <button
80 type="button"
81 className={cn(
82 "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",
83 insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8",
84 )}
85 {...pointerFocusProps}
86 onClick={onInterrupt}
87 aria-label="Stop generation"
88 >
89 <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
90 <rect x="2" y="2" width="8" height="8" rx="1.5" />
91 </svg>
92 </button>
93 );
94
7895 if (pendingAction) {
7996 return (
8097 <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
98 {isRunning ? renderStopGenerationButton(true) : null}
8199 {pendingAction.questionIndex > 0 ? (
82100 compact ? (
83101 <Button
......@@ -130,19 +148,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
130148 }
131149
132150 if (isRunning) {
133 return (
134 <button
135 type="button"
136 className="flex size-8 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 sm:h-8 sm:w-8"
137 {...pointerFocusProps}
138 onClick={onInterrupt}
139 aria-label="Stop generation"
140 >
141 <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
142 <rect x="2" y="2" width="8" height="8" rx="1.5" />
143 </svg>
144 </button>
145 );
151 return renderStopGenerationButton(false);
146152 }
147153
148154 if (showPlanFollowUpPrompt) {