Skip to content

Commit ccea1f9

Browse files
committed
Refine assistant message copy button state and UX
- make `MessageCopyButton` configurable with disabled state, titles, style props, and copy/error callbacks - show assistant copy action in timeline metadata, disable it while streaming, and add toast feedback - extract and test copy-visibility logic for streaming/empty assistant messages
1 parent 0de5742 commit ccea1f9

5 files changed

Lines changed: 231 additions & 17 deletions

File tree

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

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,63 @@ import { memo } from "react";
22
import { CopyIcon, CheckIcon } from "lucide-react";
33
import { Button } from "../ui/button";
44
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
5+
import { cn } from "~/lib/utils";
56

6-
export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
7-
const { copyToClipboard, isCopied } = useCopyToClipboard();
7+
type CopyCallbacks = {
8+
onCopy?: () => void;
9+
onError?: (error: Error) => void;
10+
};
11+
12+
export const MessageCopyButton = memo(function MessageCopyButton({
13+
text,
14+
label,
15+
title = "Copy message",
16+
disabled = false,
17+
disabledTitle,
18+
size = "xs",
19+
variant = "outline",
20+
className,
21+
onCopy,
22+
onError,
23+
}: {
24+
text: string;
25+
label?: string;
26+
title?: string;
27+
disabled?: boolean;
28+
disabledTitle?: string;
29+
size?: "xs" | "icon-xs";
30+
variant?: "outline" | "ghost";
31+
className?: string;
32+
onCopy?: () => void;
33+
onError?: (error: Error) => void;
34+
}) {
35+
const { copyToClipboard, isCopied } = useCopyToClipboard<CopyCallbacks>({
36+
onCopy: (callbacks) => {
37+
callbacks.onCopy?.();
38+
},
39+
onError: (error, callbacks) => {
40+
callbacks.onError?.(error);
41+
},
42+
});
43+
const buttonTitle = disabled ? (disabledTitle ?? title) : isCopied ? "Copied" : title;
44+
const copyCallbacks = {
45+
...(onCopy ? { onCopy } : {}),
46+
...(onError ? { onError } : {}),
47+
};
848

949
return (
1050
<Button
1151
type="button"
12-
size="xs"
13-
variant="outline"
14-
onClick={() => copyToClipboard(text)}
15-
title="Copy message"
52+
size={size}
53+
variant={variant}
54+
className={cn(className)}
55+
disabled={disabled}
56+
onClick={() => copyToClipboard(text, copyCallbacks)}
57+
title={buttonTitle}
58+
aria-label={buttonTitle}
1659
>
1760
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
61+
{label ? <span>{label}</span> : null}
1862
</Button>
1963
);
2064
});

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
2+
import {
3+
computeMessageDurationStart,
4+
normalizeCompactToolLabel,
5+
resolveAssistantMessageCopyState,
6+
} from "./MessagesTimeline.logic";
37

48
describe("computeMessageDurationStart", () => {
59
it("returns message createdAt when there is no preceding user message", () => {
@@ -143,3 +147,57 @@ describe("normalizeCompactToolLabel", () => {
143147
expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
144148
});
145149
});
150+
151+
describe("resolveAssistantMessageCopyState", () => {
152+
it("returns enabled copy state for completed assistant messages", () => {
153+
expect(
154+
resolveAssistantMessageCopyState({
155+
text: "Ship it",
156+
streaming: false,
157+
}),
158+
).toEqual({
159+
disabled: false,
160+
text: "Ship it",
161+
visible: true,
162+
});
163+
});
164+
165+
it("keeps copy visible but disabled for streaming assistant messages", () => {
166+
expect(
167+
resolveAssistantMessageCopyState({
168+
text: "Still streaming",
169+
streaming: true,
170+
}),
171+
).toEqual({
172+
disabled: true,
173+
text: "Still streaming",
174+
visible: true,
175+
});
176+
});
177+
178+
it("hides copy for empty completed assistant messages", () => {
179+
expect(
180+
resolveAssistantMessageCopyState({
181+
text: " ",
182+
streaming: false,
183+
}),
184+
).toEqual({
185+
disabled: false,
186+
text: null,
187+
visible: false,
188+
});
189+
});
190+
191+
it("keeps copy visible while an empty assistant message is streaming", () => {
192+
expect(
193+
resolveAssistantMessageCopyState({
194+
text: null,
195+
streaming: true,
196+
}),
197+
).toEqual({
198+
disabled: true,
199+
text: null,
200+
visible: true,
201+
});
202+
});
203+
});

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,18 @@ export function computeMessageDurationStart(
2727
export function normalizeCompactToolLabel(value: string): string {
2828
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
2929
}
30+
31+
export function resolveAssistantMessageCopyState({
32+
text,
33+
streaming,
34+
}: {
35+
text: string | null;
36+
streaming: boolean;
37+
}) {
38+
const hasText = text !== null && text.trim().length > 0;
39+
return {
40+
disabled: streaming,
41+
text: hasText ? text : null,
42+
visible: streaming || hasText,
43+
};
44+
}

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,64 @@ describe("MessagesTimeline", () => {
9696
expect(markup).toContain("lucide-terminal");
9797
expect(markup).toContain("yoo what&#x27;s ");
9898
});
99+
100+
it("shows assistant copy disabled while streaming and enabled once complete", async () => {
101+
const { MessagesTimeline } = await import("./MessagesTimeline");
102+
const markup = renderToStaticMarkup(
103+
<MessagesTimeline
104+
hasMessages
105+
isWorking={false}
106+
activeTurnInProgress={false}
107+
activeTurnStartedAt={null}
108+
scrollContainer={null}
109+
timelineEntries={[
110+
{
111+
id: "entry-assistant-complete",
112+
kind: "message",
113+
createdAt: "2026-03-17T19:12:28.000Z",
114+
message: {
115+
id: MessageId.makeUnsafe("message-assistant-complete"),
116+
role: "assistant",
117+
text: "Completed response",
118+
createdAt: "2026-03-17T19:12:28.000Z",
119+
completedAt: "2026-03-17T19:12:30.000Z",
120+
streaming: false,
121+
},
122+
},
123+
{
124+
id: "entry-assistant-streaming",
125+
kind: "message",
126+
createdAt: "2026-03-17T19:12:31.000Z",
127+
message: {
128+
id: MessageId.makeUnsafe("message-assistant-streaming"),
129+
role: "assistant",
130+
text: "Partial response",
131+
createdAt: "2026-03-17T19:12:31.000Z",
132+
streaming: true,
133+
},
134+
},
135+
]}
136+
completionDividerBeforeEntryId={null}
137+
completionSummary={null}
138+
turnDiffSummaryByAssistantMessageId={new Map()}
139+
nowIso="2026-03-17T19:12:35.000Z"
140+
expandedWorkGroups={{}}
141+
onToggleWorkGroup={() => {}}
142+
onOpenTurnDiff={() => {}}
143+
revertTurnCountByUserMessageId={new Map()}
144+
onRevertUserMessage={() => {}}
145+
isRevertingCheckpoint={false}
146+
onImageExpand={() => {}}
147+
markdownCwd={undefined}
148+
resolvedTheme="light"
149+
timestampFormat="locale"
150+
workspaceRoot={undefined}
151+
/>,
152+
);
153+
154+
expect((markup.match(/title="Copy assistant response"/g) ?? []).length).toBe(1);
155+
expect((markup.match(/title="Copy available when response completes"/g) ?? []).length).toBe(1);
156+
expect((markup.match(/disabled=""/g) ?? []).length).toBe(1);
157+
expect(markup).not.toContain(">Copy<");
158+
});
99159
});

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

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
4141
import { ChangedFilesTree } from "./ChangedFilesTree";
4242
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
4343
import { MessageCopyButton } from "./MessageCopyButton";
44-
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
44+
import {
45+
computeMessageDurationStart,
46+
normalizeCompactToolLabel,
47+
resolveAssistantMessageCopyState,
48+
} from "./MessagesTimeline.logic";
4549
import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
4650
import {
4751
deriveDisplayedUserMessageState,
@@ -50,6 +54,7 @@ import {
5054
import { cn } from "~/lib/utils";
5155
import { type TimestampFormat } from "../../appSettings";
5256
import { formatTimestamp } from "../../timestampFormat";
57+
import { toastManager } from "../ui/toast";
5358
import {
5459
buildInlineTerminalContextText,
5560
formatInlineTerminalContextLabel,
@@ -437,6 +442,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
437442
row.message.role === "assistant" &&
438443
(() => {
439444
const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
445+
const assistantCopyState = resolveAssistantMessageCopyState({
446+
text: row.message.text ?? null,
447+
streaming: row.message.streaming,
448+
});
440449
return (
441450
<>
442451
{row.showCompletionDivider && (
@@ -510,15 +519,43 @@ export const MessagesTimeline = memo(function MessagesTimeline({
510519
</div>
511520
);
512521
})()}
513-
<p className="mt-1.5 text-[10px] text-muted-foreground/30">
514-
{formatMessageMeta(
515-
row.message.createdAt,
516-
row.message.streaming
517-
? formatElapsed(row.durationStart, nowIso)
518-
: formatElapsed(row.durationStart, row.message.completedAt),
519-
timestampFormat,
520-
)}
521-
</p>
522+
<div className="mt-1.5 flex items-center justify-between gap-2">
523+
<div className="flex items-center gap-1.5">
524+
{assistantCopyState.visible ? (
525+
<MessageCopyButton
526+
text={assistantCopyState.text ?? ""}
527+
title="Copy assistant response"
528+
disabled={assistantCopyState.disabled}
529+
disabledTitle="Copy available when response completes"
530+
size="icon-xs"
531+
variant="outline"
532+
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"
533+
onCopy={() => {
534+
toastManager.add({
535+
type: "success",
536+
title: "Assistant response copied",
537+
});
538+
}}
539+
onError={(error) => {
540+
toastManager.add({
541+
type: "error",
542+
title: "Failed to copy assistant response",
543+
description: error.message,
544+
});
545+
}}
546+
/>
547+
) : null}
548+
</div>
549+
<p className="text-[10px] text-muted-foreground/30">
550+
{formatMessageMeta(
551+
row.message.createdAt,
552+
row.message.streaming
553+
? formatElapsed(row.durationStart, nowIso)
554+
: formatElapsed(row.durationStart, row.message.completedAt),
555+
timestampFormat,
556+
)}
557+
</p>
558+
</div>
522559
</div>
523560
</>
524561
);

0 commit comments

Comments
 (0)