Skip to content

Commit a4e170b

Browse files
committed
Show disabled assistant copy button while streaming
1 parent ee61e27 commit a4e170b

5 files changed

Lines changed: 75 additions & 3 deletions

File tree

apps/web/src/components/chat/MessageCopyButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
1010
size = "xs",
1111
variant = "outline",
1212
className,
13+
disabled = false,
1314
onCopy,
1415
onError,
1516
}: {
@@ -18,6 +19,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
1819
size?: "xs" | "icon-xs";
1920
variant?: "outline" | "ghost";
2021
className?: string;
22+
disabled?: boolean;
2123
onCopy?: () => void;
2224
onError?: (error: Error) => void;
2325
}) {
@@ -33,6 +35,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
3335
size={size}
3436
variant={variant}
3537
className={cn(className)}
38+
disabled={disabled}
3639
onClick={() => copyToClipboard(text, undefined)}
3740
title={buttonTitle}
3841
aria-label={buttonTitle}

apps/web/src/components/chat/MessagesTimeline.logic.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,21 +158,37 @@ describe("resolveAssistantMessageCopyState", () => {
158158
streaming: false,
159159
}),
160160
).toEqual({
161+
disabled: false,
161162
text: "Ship it",
162163
visible: true,
163164
});
164165
});
165166

166-
it("hides copy while an assistant message is still streaming", () => {
167+
it("keeps copy visible but disabled while an assistant message is still streaming", () => {
167168
expect(
168169
resolveAssistantMessageCopyState({
169170
showCopyButton: true,
170171
text: "Still streaming",
171172
streaming: true,
172173
}),
173174
).toEqual({
175+
disabled: true,
174176
text: "Still streaming",
175-
visible: false,
177+
visible: true,
178+
});
179+
});
180+
181+
it("keeps copy visible while a terminal assistant message is streaming before text arrives", () => {
182+
expect(
183+
resolveAssistantMessageCopyState({
184+
showCopyButton: true,
185+
text: "",
186+
streaming: true,
187+
}),
188+
).toEqual({
189+
disabled: true,
190+
text: null,
191+
visible: true,
176192
});
177193
});
178194

@@ -184,6 +200,7 @@ describe("resolveAssistantMessageCopyState", () => {
184200
streaming: false,
185201
}),
186202
).toEqual({
203+
disabled: false,
187204
text: null,
188205
visible: false,
189206
});
@@ -197,6 +214,7 @@ describe("resolveAssistantMessageCopyState", () => {
197214
streaming: false,
198215
}),
199216
).toEqual({
217+
disabled: false,
200218
text: "Interim thought",
201219
visible: false,
202220
});

apps/web/src/components/chat/MessagesTimeline.logic.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export function resolveAssistantMessageCopyState({
7272
const hasText = text !== null && text.trim().length > 0;
7373
return {
7474
text: hasText ? text : null,
75-
visible: showCopyButton && hasText && !streaming,
75+
visible: showCopyButton && (hasText || streaming),
76+
disabled: streaming,
7677
};
7778
}
7879

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,53 @@ describe("MessagesTimeline", () => {
108108
expect(markup).toContain("Context compacted");
109109
expect(markup).toContain("Work log");
110110
});
111+
112+
it("renders the assistant copy button in a disabled state while streaming", async () => {
113+
const { MessagesTimeline } = await import("./MessagesTimeline");
114+
const markup = renderToStaticMarkup(
115+
<MessagesTimeline
116+
hasMessages
117+
isWorking={false}
118+
activeTurnInProgress
119+
activeTurnStartedAt="2026-03-17T19:12:28.000Z"
120+
scrollContainer={null}
121+
timelineEntries={[
122+
{
123+
id: "assistant-entry",
124+
kind: "message",
125+
createdAt: "2026-03-17T19:12:28.000Z",
126+
message: {
127+
id: MessageId.make("assistant-1"),
128+
role: "assistant",
129+
text: "",
130+
turnId: "turn-1" as never,
131+
createdAt: "2026-03-17T19:12:28.000Z",
132+
streaming: true,
133+
},
134+
},
135+
]}
136+
completionDividerBeforeEntryId={null}
137+
completionSummary={null}
138+
turnDiffSummaryByAssistantMessageId={new Map()}
139+
nowIso="2026-03-17T19:12:30.000Z"
140+
expandedWorkGroups={{}}
141+
onToggleWorkGroup={() => {}}
142+
changedFilesExpandedByTurnId={{}}
143+
onSetChangedFilesExpanded={() => {}}
144+
onOpenTurnDiff={() => {}}
145+
revertTurnCountByUserMessageId={new Map()}
146+
onRevertUserMessage={() => {}}
147+
isRevertingCheckpoint={false}
148+
onImageExpand={() => {}}
149+
activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID}
150+
markdownCwd={undefined}
151+
resolvedTheme="light"
152+
timestampFormat="locale"
153+
workspaceRoot={undefined}
154+
/>,
155+
);
156+
157+
expect(markup).toContain('aria-label="Copy assistant response"');
158+
expect(markup).toContain("disabled");
159+
}, 10_000);
111160
});

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
537537
size="icon-xs"
538538
variant="outline"
539539
className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"
540+
disabled={assistantCopyState.disabled}
540541
onCopy={() => {
541542
toastManager.add({
542543
type: "success",

0 commit comments

Comments
 (0)