Skip to content

Commit 26cc1ff

Browse files
Add assistant message copy action and harden related test/storage fallbacks (#1211)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 97880e8 commit 26cc1ff

8 files changed

Lines changed: 348 additions & 31 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3353,6 +3353,7 @@ export default function ChatView(props: ChatViewProps) {
33533353
hasMessages={timelineEntries.length > 0}
33543354
isWorking={isWorking}
33553355
activeTurnInProgress={isWorking || !latestTurnSettled}
3356+
activeTurnId={activeLatestTurn?.turnId ?? null}
33563357
activeTurnStartedAt={activeWorkStartedAt}
33573358
scrollContainer={messagesScrollElement}
33583359
timelineEntries={timelineEntries}
Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,82 @@
1-
import { memo } from "react";
1+
import { memo, useRef } 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";
6+
import { anchoredToastManager } from "../ui/toast";
7+
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
58

6-
export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
7-
const { copyToClipboard, isCopied } = useCopyToClipboard();
9+
const ANCHORED_TOAST_TIMEOUT_MS = 1000;
10+
const onCopy = (ref: React.RefObject<HTMLButtonElement | null>) => {
11+
if (ref.current) {
12+
anchoredToastManager.add({
13+
data: {
14+
tooltipStyle: true,
15+
},
16+
positionerProps: {
17+
anchor: ref.current,
18+
},
19+
timeout: ANCHORED_TOAST_TIMEOUT_MS,
20+
title: "Copied!",
21+
});
22+
}
23+
};
24+
25+
const onCopyError = (ref: React.RefObject<HTMLButtonElement | null>, error: Error) => {
26+
if (ref.current) {
27+
anchoredToastManager.add({
28+
data: {
29+
tooltipStyle: true,
30+
},
31+
positionerProps: {
32+
anchor: ref.current,
33+
},
34+
timeout: ANCHORED_TOAST_TIMEOUT_MS,
35+
title: "Failed to copy",
36+
description: error.message,
37+
});
38+
}
39+
};
40+
41+
export const MessageCopyButton = memo(function MessageCopyButton({
42+
text,
43+
size = "xs",
44+
variant = "outline",
45+
className,
46+
}: {
47+
text: string;
48+
size?: "xs" | "icon-xs";
49+
variant?: "outline" | "ghost";
50+
className?: string;
51+
}) {
52+
const ref = useRef<HTMLButtonElement>(null);
53+
const { copyToClipboard, isCopied } = useCopyToClipboard<void>({
54+
onCopy: () => onCopy(ref),
55+
onError: (error: Error) => onCopyError(ref, error),
56+
timeout: ANCHORED_TOAST_TIMEOUT_MS,
57+
});
858

959
return (
10-
<Button
11-
type="button"
12-
size="xs"
13-
variant="outline"
14-
onClick={() => copyToClipboard(text)}
15-
title="Copy message"
16-
>
17-
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
18-
</Button>
60+
<Tooltip>
61+
<TooltipTrigger
62+
render={
63+
<Button
64+
aria-label="Copy link"
65+
disabled={isCopied}
66+
onClick={() => copyToClipboard(text)}
67+
ref={ref}
68+
type="button"
69+
size={size}
70+
variant={variant}
71+
className={cn(className)}
72+
/>
73+
}
74+
>
75+
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
76+
</TooltipTrigger>
77+
<TooltipPopup>
78+
<p>Copy to clipboard</p>
79+
</TooltipPopup>
80+
</Tooltip>
1981
);
2082
});

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

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

49
describe("computeMessageDurationStart", () => {
510
it("returns message createdAt when there is no preceding user message", () => {
@@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => {
143148
expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
144149
});
145150
});
151+
152+
describe("resolveAssistantMessageCopyState", () => {
153+
it("returns enabled copy state for completed assistant messages", () => {
154+
expect(
155+
resolveAssistantMessageCopyState({
156+
showCopyButton: true,
157+
text: "Ship it",
158+
streaming: false,
159+
}),
160+
).toEqual({
161+
text: "Ship it",
162+
visible: true,
163+
});
164+
});
165+
166+
it("hides copy while an assistant message is still streaming", () => {
167+
expect(
168+
resolveAssistantMessageCopyState({
169+
showCopyButton: true,
170+
text: "Still streaming",
171+
streaming: true,
172+
}),
173+
).toEqual({
174+
text: "Still streaming",
175+
visible: false,
176+
});
177+
});
178+
179+
it("hides copy for empty completed assistant messages", () => {
180+
expect(
181+
resolveAssistantMessageCopyState({
182+
showCopyButton: true,
183+
text: " ",
184+
streaming: false,
185+
}),
186+
).toEqual({
187+
text: null,
188+
visible: false,
189+
});
190+
});
191+
192+
it("hides copy for non-terminal assistant messages", () => {
193+
expect(
194+
resolveAssistantMessageCopyState({
195+
showCopyButton: false,
196+
text: "Interim thought",
197+
streaming: false,
198+
}),
199+
).toEqual({
200+
text: "Interim thought",
201+
visible: false,
202+
});
203+
});
204+
});
205+
206+
describe("deriveMessagesTimelineRows", () => {
207+
it("only enables assistant copy for the terminal assistant message in a turn", () => {
208+
const rows = deriveMessagesTimelineRows({
209+
timelineEntries: [
210+
{
211+
id: "user-1-entry",
212+
kind: "message",
213+
createdAt: "2026-01-01T00:00:00Z",
214+
message: {
215+
id: "user-1" as never,
216+
role: "user",
217+
text: "Write a poem",
218+
turnId: null,
219+
createdAt: "2026-01-01T00:00:00Z",
220+
streaming: false,
221+
},
222+
},
223+
{
224+
id: "assistant-thought-entry",
225+
kind: "message",
226+
createdAt: "2026-01-01T00:00:10Z",
227+
message: {
228+
id: "assistant-thought" as never,
229+
role: "assistant",
230+
text: "I should ground this first.",
231+
turnId: "turn-1" as never,
232+
createdAt: "2026-01-01T00:00:10Z",
233+
completedAt: "2026-01-01T00:00:11Z",
234+
streaming: false,
235+
},
236+
},
237+
{
238+
id: "assistant-final-entry",
239+
kind: "message",
240+
createdAt: "2026-01-01T00:00:20Z",
241+
message: {
242+
id: "assistant-final" as never,
243+
role: "assistant",
244+
text: "Here is the poem.",
245+
turnId: "turn-1" as never,
246+
createdAt: "2026-01-01T00:00:20Z",
247+
completedAt: "2026-01-01T00:00:30Z",
248+
streaming: false,
249+
},
250+
},
251+
],
252+
completionDividerBeforeEntryId: "assistant-final-entry",
253+
isWorking: false,
254+
activeTurnStartedAt: null,
255+
});
256+
257+
const assistantRows = rows.filter(
258+
(row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
259+
row.kind === "message" && row.message.role === "assistant",
260+
);
261+
262+
expect(assistantRows).toHaveLength(2);
263+
expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);
264+
expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
265+
expect(assistantRows[1]?.showCompletionDivider).toBe(true);
266+
});
267+
});

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type MessagesTimelineRow =
2727
message: ChatMessage;
2828
durationStart: string;
2929
showCompletionDivider: boolean;
30+
showAssistantCopyButton: boolean;
3031
}
3132
| {
3233
kind: "proposed-plan";
@@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string {
5960
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
6061
}
6162

63+
export function resolveAssistantMessageCopyState({
64+
text,
65+
showCopyButton,
66+
streaming,
67+
}: {
68+
text: string | null;
69+
showCopyButton: boolean;
70+
streaming: boolean;
71+
}) {
72+
const hasText = text !== null && text.trim().length > 0;
73+
return {
74+
text: hasText ? text : null,
75+
visible: showCopyButton && hasText && !streaming,
76+
};
77+
}
78+
79+
function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<TimelineEntry>) {
80+
const lastAssistantMessageIdByResponseKey = new Map<string, string>();
81+
let nullTurnResponseIndex = 0;
82+
83+
for (const timelineEntry of timelineEntries) {
84+
if (timelineEntry.kind !== "message") {
85+
continue;
86+
}
87+
const { message } = timelineEntry;
88+
if (message.role === "user") {
89+
nullTurnResponseIndex += 1;
90+
continue;
91+
}
92+
if (message.role !== "assistant") {
93+
continue;
94+
}
95+
96+
const responseKey = message.turnId
97+
? `turn:${message.turnId}`
98+
: `unkeyed:${nullTurnResponseIndex}`;
99+
lastAssistantMessageIdByResponseKey.set(responseKey, message.id);
100+
}
101+
102+
return new Set(lastAssistantMessageIdByResponseKey.values());
103+
}
104+
62105
export function deriveMessagesTimelineRows(input: {
63106
timelineEntries: ReadonlyArray<TimelineEntry>;
64107
completionDividerBeforeEntryId: string | null;
@@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: {
69112
const durationStartByMessageId = computeMessageDurationStart(
70113
input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
71114
);
115+
const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
72116

73117
for (let index = 0; index < input.timelineEntries.length; index += 1) {
74118
const timelineEntry = input.timelineEntries[index];
@@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: {
115159
showCompletionDivider:
116160
timelineEntry.message.role === "assistant" &&
117161
input.completionDividerBeforeEntryId === timelineEntry.id,
162+
showAssistantCopyButton:
163+
timelineEntry.message.role === "assistant" &&
164+
terminalAssistantMessageIds.has(timelineEntry.message.id),
118165
});
119166
}
120167

0 commit comments

Comments
 (0)