mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
+98
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user