From 543fb7c5635737852cbb380b317ce685a04d84de Mon Sep 17 00:00:00 2001 From: Rafael Soley <110256307+byrafael@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:13:03 -0600 Subject: [PATCH 1/2] Add copy button to assistant messages - Show copy action for non-empty assistant text - Keep empty responses uncopiable and covered by tests --- .../components/chat/MessagesTimeline.test.tsx | 137 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 26 ++-- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..8877f0eb7c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -140,4 +140,141 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders a copy button for assistant messages with text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("Here is the agent response."); + expect(markup).toContain('title="Copy message"'); + }); + + it("does not render a copy button for empty non-streaming assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("(empty response)"); + expect(markup).not.toContain('title="Copy message"'); + }); + + it("renders a copy button for streaming assistant messages with buffered text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("Partial streamed output"); + expect(markup).toContain('title="Copy message"'); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3e462f7fe..cab1e192c1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -437,6 +437,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const canCopyMessage = row.message.text.trim().length > 0; return ( <> {row.showCompletionDivider && ( @@ -448,7 +449,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )} -
+
); })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

+
+

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, + )} +

+
+ {canCopyMessage && } +
+
); From 36d0c0b8d25b7e7f5f9e326c96488f17c4ace9a4 Mon Sep 17 00:00:00 2001 From: Rafael Soley <110256307+byrafael@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:30:50 -0600 Subject: [PATCH 2/2] Show assistant copy button on message hover - scope the assistant message group for hover state - reveal the copy action only when hovering that message --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index cab1e192c1..7b13f4588f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -449,7 +449,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)} -
+
-
+
{canCopyMessage && }