feat(site/src/pages/AgentsPage): add copy message button to chat messages (#23850)

Add a hover-reveal copy button to both user and assistant messages
in the agents chat. Copies raw markdown to the clipboard, preserving
formatting for pasting into markdown-aware editors.

The button uses the existing useClipboard hook and matches the
visual pattern established by the edit button on user messages
(opacity-0 with group-hover reveal and focus-visible support).

For assistant messages, the button sits below the response content.
For user messages, it sits inline alongside the edit button.
Messages with no copyable text content (e.g. tool-only messages)
do not show the button.
This commit is contained in:
Mathias Fredriksson
2026-04-01 16:08:25 +03:00
committed by GitHub
parent 2a51687ff3
commit 196dc51edf
2 changed files with 275 additions and 53 deletions
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { expect, fn, spyOn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
@@ -570,3 +570,169 @@ export const StickyUserMessageStructure: Story = {
expect(canvas.getByText("Second prompt")).toBeVisible();
},
};
/** Copy + edit toolbar appears below user messages on hover. */
export const UserMessageCopyButton: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Can you fix this bug?" }],
},
]),
onEditUserMessage: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible for the screenshot.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
const copyButton = canvas.getByRole("button", {
name: "Copy message",
});
expect(copyButton).toBeInTheDocument();
const editButton = canvas.getByRole("button", {
name: "Edit message",
});
expect(editButton).toBeInTheDocument();
},
};
/** Copy button is present on assistant messages below the response. */
export const AssistantMessageCopyButton: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Explain this code" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "text",
text: "This function handles **authentication** by checking the JWT token.\n\n```go\nfunc auth(r *http.Request) error {\n\treturn nil\n}\n```",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The assistant copy button is always visible below
// the response content.
const wrapper = canvas.getByTestId("assistant-copy-button");
const copyBtn = within(wrapper).getByRole("button", {
name: "Copy message",
});
expect(copyBtn).toBeInTheDocument();
},
};
/** No copy button when assistant message has no markdown content. */
export const AssistantMessageNoCopyWhenToolOnly: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Run the tests" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: { command: "go test ./..." },
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { output: "PASS" },
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Tool-only assistant message should not have a copy button.
expect(
canvas.queryByTestId("assistant-copy-button"),
).not.toBeInTheDocument();
},
};
/** Copy button calls clipboard API with the raw markdown text. */
export const CopyButtonWritesToClipboard: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "What is the answer?" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [{ type: "text", text: "Here is the **answer**." }],
},
]),
},
play: async ({ canvasElement }) => {
const originalClipboard = navigator.clipboard;
const writeText = fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
try {
const canvas = within(canvasElement);
// Find the always-visible assistant copy button.
const wrapper = canvas.getByTestId("assistant-copy-button");
const copyBtn = within(wrapper).getByRole("button", {
name: "Copy message",
});
await userEvent.click(copyBtn);
expect(writeText).toHaveBeenCalledWith("Here is the **answer**.");
} finally {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
writable: true,
configurable: true,
});
}
},
};
@@ -11,6 +11,7 @@ import {
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { FileReferenceChip } from "#/components/ChatMessageInput/FileReferenceNode";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
@@ -253,6 +254,7 @@ export const BlockList: FC<{
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
urlTransform?: UrlTransform;
afterResponseSlot?: React.ReactNode;
}> = ({
blocks,
tools,
@@ -266,6 +268,7 @@ export const BlockList: FC<{
onImageClick,
onTextFileClick,
urlTransform,
afterResponseSlot,
}) => {
const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
@@ -282,12 +285,17 @@ export const BlockList: FC<{
const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id));
const lastResponseIndex = blocks.reduce(
(acc, b, idx) => (b.type === "response" ? idx : acc),
-1,
);
return (
<>
{blocks.map((block, index) => {
switch (block.type) {
case "response":
return isStreaming ? (
case "response": {
const responseEl = isStreaming ? (
<SmoothedResponse
key={`${keyPrefix}-response-${index}`}
text={block.text}
@@ -302,6 +310,13 @@ export const BlockList: FC<{
{block.text}
</Response>
);
return (
<Fragment key={`${keyPrefix}-response-${index}`}>
{responseEl}
{index === lastResponseIndex ? afterResponseSlot : null}
</Fragment>
);
}
case "thinking":
return (
<ReasoningDisclosure
@@ -386,7 +401,6 @@ export const BlockList: FC<{
return null;
}
})}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
@@ -410,6 +424,7 @@ export const BlockList: FC<{
</>
);
};
const ChatMessageItem = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
@@ -421,6 +436,7 @@ const ChatMessageItem = memo<{
editingMessageId?: number | null;
savingMessageId?: number | null;
isAfterEditingMessage?: boolean;
isLastAssistantMessage?: boolean;
// When true, renders a gradient overlay inside the bubble
// that fades text out toward the bottom. Used by the sticky
// overlay to indicate truncated content.
@@ -438,6 +454,7 @@ const ChatMessageItem = memo<{
editingMessageId,
savingMessageId,
isAfterEditingMessage = false,
isLastAssistantMessage = false,
fadeFromBottom = false,
urlTransform,
mcpServers,
@@ -503,6 +520,7 @@ const ChatMessageItem = memo<{
const hasUserMessageBody =
userInlineContent.length > 0 || Boolean(parsed.markdown?.trim());
const hasFileBlocks = userFileBlocks.length > 0;
const hasCopyableContent = Boolean(parsed.markdown.trim());
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
@@ -512,7 +530,7 @@ const ChatMessageItem = memo<{
<div
className={cn(
isAfterEditingMessage && "opacity-40 pointer-events-none",
"transition-opacity duration-200",
"group/msg relative transition-opacity duration-200",
)}
>
<ConversationItem {...conversationItemProps}>
@@ -520,7 +538,7 @@ const ChatMessageItem = memo<{
<Message className="w-full max-w-none">
<MessageContent
className={cn(
"group/msg rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
editingMessageId === message.id &&
"border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
isSavingMessage && "ring-2 ring-content-secondary/40",
@@ -561,27 +579,6 @@ const ChatMessageItem = memo<{
loading
/>
)}
{onEditUserMessage && !isSavingMessage && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="-my-0.5 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-content-secondary opacity-0 transition-opacity hover:bg-surface-tertiary hover:text-content-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link group-hover/msg:opacity-100"
aria-label="Edit message"
onClick={() => {
const { text, fileBlocks } =
getEditableUserMessagePayload(message);
onEditUserMessage(message.id, text, fileBlocks);
}}
>
<PencilIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
Edit message
</TooltipContent>
</Tooltip>
)}
</div>
)}
{hasFileBlocks && (
@@ -628,6 +625,16 @@ const ChatMessageItem = memo<{
onTextFileClick={setPreviewText}
urlTransform={urlTransform}
mcpServers={mcpServers}
afterResponseSlot={
hasCopyableContent && isLastAssistantMessage ? (
<div data-testid="assistant-copy-button">
<CopyButton
text={parsed.markdown}
label="Copy message"
/>
</div>
) : undefined
}
/>
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
@@ -639,6 +646,42 @@ const ChatMessageItem = memo<{
</Message>
)}
</ConversationItem>
{isUser && (hasCopyableContent || onEditUserMessage) && (
<div
className="absolute right-0 top-full z-10 flex items-center gap-1 py-0.5 pl-6 pr-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100"
style={{
background:
"linear-gradient(to right, transparent, hsl(var(--surface-primary)) 40%)",
}}
>
{(hasCopyableContent || onEditUserMessage) && !isSavingMessage && (
<>
{hasCopyableContent && (
<CopyButton text={parsed.markdown} label="Copy message" />
)}
{onEditUserMessage && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
aria-label="Edit message"
onClick={() => {
const { text, fileBlocks } =
getEditableUserMessagePayload(message);
onEditUserMessage(message.id, text, fileBlocks);
}}
>
<PencilIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Edit message</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
)}
{previewImage && (
<ImageLightbox
src={previewImage}
@@ -997,32 +1040,45 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
return (
<div className="flex flex-col gap-3">
{parsedMessages.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
) : (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
/>
),
)}
{(() => {
const lastAssistantPerTurnIds = new Set<number>();
let lastAsstId: number | null = null;
for (const { message: m } of parsedMessages) {
if (m.role === "assistant") lastAsstId = m.id;
else if (m.role === "user" && lastAsstId != null) {
lastAssistantPerTurnIds.add(lastAsstId);
lastAsstId = null;
}
}
if (lastAsstId != null) lastAssistantPerTurnIds.add(lastAsstId);
return parsedMessages.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
) : (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
isLastAssistantMessage={lastAssistantPerTurnIds.has(message.id)}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
/>
),
);
})()}
</div>
);
},