fix(site/src/pages/AgentsPage): fix copy button toolbar regression and add missing story coverage (#23912)

Move !isSavingMessage to the outer toolbar guard so the gradient
container does not mount empty during save. Remove the now-redundant
inner guard.

Add flex to the assistant copy button wrapper div. The plain block
wrapper with an inline-flex button created a line box whose height
depended on the inherited non-integer line-height (14px * 1.625 =
22.75px strut). Sub-pixel rounding during hover repaints caused a
1px jitter. Making it a flex container eliminates the strut.

Add behavioral assertions to UserMessageCopyButton: click edit and
assert onEditUserMessage fires, click copy and assert writeText is
called with the raw markdown.

Add MultiAssistantTurnCopyButton regression story for the
isLastAssistantMessage fix.

Refs #23850
This commit is contained in:
Mathias Fredriksson
2026-04-01 18:27:27 +03:00
committed by GitHub
parent 28062862a0
commit ba734f8b10
2 changed files with 136 additions and 38 deletions
@@ -585,7 +585,7 @@ export const UserMessageCopyButton: Story = {
]),
onEditUserMessage: fn(),
},
play: async ({ canvasElement }) => {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Force the hover-reveal toolbar visible for the screenshot.
for (const el of canvasElement.querySelectorAll("[class]")) {
@@ -604,6 +604,35 @@ export const UserMessageCopyButton: Story = {
name: "Edit message",
});
expect(editButton).toBeInTheDocument();
// Behavioral: clicking edit fires onEditUserMessage with the
// correct message ID and text.
await userEvent.click(editButton);
expect(args.onEditUserMessage).toHaveBeenCalledWith(
1,
"Can you fix this bug?",
undefined,
);
// Behavioral: clicking copy writes the raw markdown to the
// clipboard.
const originalClipboard = navigator.clipboard;
const writeText = fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
try {
await userEvent.click(copyButton);
expect(writeText).toHaveBeenCalledWith("Can you fix this bug?");
} finally {
Object.defineProperty(navigator, "clipboard", {
value: originalClipboard,
writable: true,
configurable: true,
});
}
},
};
@@ -736,3 +765,71 @@ export const CopyButtonWritesToClipboard: Story = {
}
},
};
/**
* Regression: copy button appears only on the last assistant message
* in a turn that includes tool calls. The isLastAssistantMessage
* computation must skip tool-role messages when finding turn
* boundaries.
*/
export const MultiAssistantTurnCopyButton: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Help me refactor" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{ type: "text", text: "Let me check the code first." },
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "read_file",
args: { path: "main.go" },
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { output: "package main" },
},
],
},
{
...baseMessage,
id: 4,
role: "assistant",
content: [
{ type: "text", text: "Here is the **refactored** version." },
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Only the last assistant message in the turn should have the
// copy button. The first assistant message (id=2) has text but
// should not show the button because a later assistant message
// (id=4) continues the turn.
const wrappers = canvas.getAllByTestId("assistant-copy-button");
expect(wrappers).toHaveLength(1);
const copyBtn = within(wrappers[0]).getByRole("button", {
name: "Copy message",
});
expect(copyBtn).toBeInTheDocument();
},
};
@@ -627,7 +627,10 @@ const ChatMessageItem = memo<{
mcpServers={mcpServers}
afterResponseSlot={
hasCopyableContent && isLastAssistantMessage ? (
<div data-testid="assistant-copy-button">
<div
className="flex"
data-testid="assistant-copy-button"
>
<CopyButton
text={parsed.markdown}
label="Copy message"
@@ -646,42 +649,40 @@ 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>
)}
{isUser &&
!isSavingMessage &&
(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 && (
<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}