From 196dc51edfd2ea2027559257db1c6b9e46255f7a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 1 Apr 2026 16:08:25 +0300 Subject: [PATCH] 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. --- .../ConversationTimeline.stories.tsx | 168 +++++++++++++++++- .../ChatConversation/ConversationTimeline.tsx | 160 +++++++++++------ 2 files changed, 275 insertions(+), 53 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index fb0e0f05b5..6706bda66e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -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, + }); + } + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 332b2bbf20..516d192953 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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 ? ( ); + return ( + + {responseEl} + {index === lastResponseIndex ? afterResponseSlot : null} + + ); + } case "thinking": return ( ( ); }; + 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<{
@@ -520,7 +538,7 @@ const ChatMessageItem = memo<{ )} - {onEditUserMessage && !isSavingMessage && ( - - - - - - Edit message - - - )}
)} {hasFileBlocks && ( @@ -628,6 +625,16 @@ const ChatMessageItem = memo<{ onTextFileClick={setPreviewText} urlTransform={urlTransform} mcpServers={mcpServers} + afterResponseSlot={ + hasCopyableContent && isLastAssistantMessage ? ( +
+ +
+ ) : undefined + } /> {!hasRenderableContent && (
@@ -639,6 +646,42 @@ const ChatMessageItem = memo<{ )} + {isUser && (hasCopyableContent || onEditUserMessage) && ( +
+ {(hasCopyableContent || onEditUserMessage) && !isSavingMessage && ( + <> + {hasCopyableContent && ( + + )} + {onEditUserMessage && ( + + + + + Edit message + + )} + + )} +
+ )} {previewImage && ( ( return (
- {parsedMessages.map(({ message, parsed }) => - message.role === "user" ? ( - - ) : ( - - ), - )} + {(() => { + const lastAssistantPerTurnIds = new Set(); + 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" ? ( + + ) : ( + + ), + ); + })()}
); },