mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+167
-1
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user