refactor(site): unify message copy/edit UX across user and assistant messages (#24039)

Aligns the copy/edit action bar so both user and assistant messages use
the same hover-to-reveal pattern.

## Changes

- Replace bifurcated copy UX (inline `afterResponseSlot` for assistant,
floating toolbar for user) with a single unified action bar using
`CopyButton` + optional edit `Button`
- Remove `BlockList` `afterResponseSlot` prop and related machinery
- Remove per-message `copyHovered`/`useClipboard` state and left-border
highlight effect
- Remove `lastAssistantPerTurnIds`/`isTurnActive` computation — all
messages with content get actions on hover
- Hide actions on mid-chain assistant messages (only last in consecutive
chain shows buttons)
- Reduce inter-message gap from `gap-3` to `gap-2`
- Shrink action buttons to `size-6` for tighter vertical spacing
- Add 8px sticky top offset for user messages

> 🤖 Generated by Coder Agents
This commit is contained in:
Kyle Carberry
2026-04-05 13:27:21 -04:00
committed by GitHub
parent 5b32c4d79d
commit 8bdc35f91f
3 changed files with 131 additions and 178 deletions
@@ -64,7 +64,6 @@ const defaultArgs: Omit<
"parsedMessages"
> = {
subagentTitles: new Map(),
isTurnActive: false,
};
const meta: Meta<typeof ConversationTimeline> = {
@@ -572,7 +571,7 @@ export const StickyUserMessageStructure: Story = {
},
};
/** Copy + edit toolbar appears below user messages on hover. */
/** Copy + edit actions appear below user messages on hover. */
export const UserMessageCopyButton: Story = {
args: {
...defaultArgs,
@@ -637,7 +636,7 @@ export const UserMessageCopyButton: Story = {
},
};
/** Copy button is present on assistant messages below the response. */
/** Copy button is present on assistant messages on hover. */
export const AssistantMessageCopyButton: Story = {
args: {
...defaultArgs,
@@ -663,10 +662,20 @@ export const AssistantMessageCopyButton: Story = {
},
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", {
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
const actions = canvas.getAllByTestId("message-actions");
expect(actions.length).toBeGreaterThanOrEqual(1);
// The last message-actions belongs to the assistant.
const assistantActions = actions[actions.length - 1];
const copyBtn = within(assistantActions).getByRole("button", {
name: "Copy message",
});
expect(copyBtn).toBeInTheDocument();
@@ -713,10 +722,19 @@ export const AssistantMessageNoCopyWhenToolOnly: Story = {
},
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();
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// Only the user message should have actions; the tool-only
// assistant message has no copyable content.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(1);
},
};
@@ -750,9 +768,19 @@ export const CopyButtonWritesToClipboard: Story = {
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", {
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// Find the assistant's copy button (last message-actions).
const actions = canvas.getAllByTestId("message-actions");
const assistantActions = actions[actions.length - 1];
const copyBtn = within(assistantActions).getByRole("button", {
name: "Copy message",
});
await userEvent.click(copyBtn);
@@ -767,15 +795,10 @@ export const CopyButtonWritesToClipboard: Story = {
},
};
/**
* When the turn is still active (isTurnActive=true), the trailing
* assistant message should NOT show a copy button because the
* content is not yet final.
*/
export const NoCopyButtonDuringActiveTurn: Story = {
/** All messages get copy actions regardless of turn state. */
export const CopyButtonDuringActiveTurn: Story = {
args: {
...defaultArgs,
isTurnActive: true,
parsedMessages: buildMessages([
{
...baseMessage,
@@ -793,18 +816,22 @@ export const NoCopyButtonDuringActiveTurn: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.queryByTestId("assistant-copy-button"),
).not.toBeInTheDocument();
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// Both user and assistant messages should have actions.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(2);
},
};
/**
* 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.
*/
/** All assistant messages with text content get a copy button. */
export const MultiAssistantTurnCopyButton: Story = {
args: {
...defaultArgs,
@@ -853,16 +880,19 @@ export const MultiAssistantTurnCopyButton: Story = {
},
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();
// Force the hover-reveal toolbar visible.
for (const el of canvasElement.querySelectorAll("[class]")) {
if (
el instanceof HTMLElement &&
el.className.includes("group-hover/msg:opacity-100")
) {
el.style.opacity = "1";
}
}
// The first assistant message (id=2) is mid-chain so its
// actions are hidden. Only the user and the last assistant
// (id=4) get action bars.
const actions = canvas.getAllByTestId("message-actions");
expect(actions).toHaveLength(2);
},
};
@@ -1,4 +1,4 @@
import { CopyIcon, FileTextIcon, PencilIcon } from "lucide-react";
import { FileTextIcon, PencilIcon } from "lucide-react";
import {
type FC,
Fragment,
@@ -10,7 +10,6 @@ import {
} from "react";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { CheckIcon } from "#/components/AnimatedIcons/Check";
import { Button } from "#/components/Button/Button";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { Spinner } from "#/components/Spinner/Spinner";
@@ -19,7 +18,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useClipboard } from "#/hooks/useClipboard";
import { cn } from "#/utils/cn";
import {
decodeInlineTextAttachment,
@@ -257,7 +255,6 @@ export const BlockList: FC<{
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
urlTransform?: UrlTransform;
afterResponseSlot?: React.ReactNode;
}> = ({
blocks,
tools,
@@ -271,7 +268,6 @@ export const BlockList: FC<{
onImageClick,
onTextFileClick,
urlTransform,
afterResponseSlot,
}) => {
const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
@@ -288,11 +284,6 @@ 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) => {
@@ -316,7 +307,6 @@ export const BlockList: FC<{
return (
<Fragment key={`${keyPrefix}-response-${index}`}>
{responseEl}
{index === lastResponseIndex ? afterResponseSlot : null}
</Fragment>
);
}
@@ -439,8 +429,8 @@ const ChatMessageItem = memo<{
editingMessageId?: number | null;
savingMessageId?: number | null;
isAfterEditingMessage?: boolean;
isLastAssistantMessage?: boolean;
isLastInConversation?: boolean;
hideActions?: 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.
@@ -458,9 +448,9 @@ const ChatMessageItem = memo<{
editingMessageId,
savingMessageId,
isAfterEditingMessage = false,
isLastAssistantMessage = false,
isLastInConversation = false,
hideActions = false,
fadeFromBottom = false,
urlTransform,
mcpServers,
subagentTitles,
@@ -471,8 +461,6 @@ const ChatMessageItem = memo<{
const isSavingMessage = savingMessageId === message.id;
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewText, setPreviewText] = useState<string | null>(null);
const [copyHovered, setCopyHovered] = useState(false);
const { showCopiedSuccess, copyToClipboard } = useClipboard();
if (
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
@@ -619,26 +607,9 @@ const ChatMessageItem = memo<{
</MessageContent>
</Message>
) : (
<Message
className={cn(
"w-full",
isLastAssistantMessage &&
hasCopyableContent &&
!isLastInConversation &&
"pb-5",
)}
>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div
className={cn(
"relative space-y-3 overflow-visible",
"before:content-[''] before:pointer-events-none before:absolute before:-left-2 before:top-0 before:h-[calc(100%-4px)] before:w-0.5 before:rounded-full before:bg-border before:opacity-0 before:transition-opacity",
(copyHovered || showCopiedSuccess) &&
isLastAssistantMessage &&
hasCopyableContent &&
"before:opacity-100",
)}
>
<div className="relative space-y-3 overflow-visible">
<BlockList
blocks={parsed.blocks}
tools={parsed.tools}
@@ -650,42 +621,6 @@ const ChatMessageItem = memo<{
onTextFileClick={setPreviewText}
urlTransform={urlTransform}
mcpServers={mcpServers}
afterResponseSlot={
hasCopyableContent && isLastAssistantMessage ? (
<div
className="flex !mt-0 -ml-2.5"
data-testid="assistant-copy-button"
>
<Tooltip
open={copyHovered || showCopiedSuccess || undefined}
>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className=""
onClick={() => {
copyToClipboard(parsed.markdown);
setCopyHovered(false);
}}
onMouseEnter={() => setCopyHovered(true)}
onMouseLeave={() => setCopyHovered(false)}
>
{showCopiedSuccess ? (
<CheckIcon />
) : (
<CopyIcon />
)}
<span className="sr-only">Copy message</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
{showCopiedSuccess ? "Copied!" : "Copy message"}
</TooltipContent>
</Tooltip>
</div>
) : undefined
}
/>
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
@@ -697,25 +632,26 @@ const ChatMessageItem = memo<{
</Message>
)}
</ConversationItem>
{isUser &&
!isSavingMessage &&
(hasCopyableContent || onEditUserMessage) && (
{!hideActions &&
(hasCopyableContent || (isUser && 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%)",
}}
className="mt-0.5 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100"
data-testid="message-actions"
>
{hasCopyableContent && (
<CopyButton text={parsed.markdown} label="Copy message" />
<CopyButton
text={parsed.markdown}
label="Copy message"
className="size-6"
/>
)}
{onEditUserMessage && (
{isUser && 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"
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Edit message"
onClick={() => {
const { text, fileBlocks } =
@@ -723,10 +659,11 @@ const ChatMessageItem = memo<{
onEditUserMessage(message.id, text, fileBlocks);
}}
>
<PencilIcon className="size-3.5" />
</button>
<PencilIcon />
<span className="sr-only">Edit message</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit message</TooltipContent>
<TooltipContent side="bottom">Edit message</TooltipContent>
</Tooltip>
)}
</div>
@@ -813,6 +750,8 @@ const StickyUserMessage = memo<{
if (!scroller) return;
const MIN_HEIGHT = 72;
const STICKY_TOP = 8;
let scrollerTop = scroller.getBoundingClientRect().top;
let scrollerHeight = scroller.clientHeight;
@@ -827,7 +766,8 @@ const StickyUserMessage = memo<{
if (tooTall) {
container.style.setProperty("--clip-h", `${fullHeight}px`);
container.style.setProperty("--fade-opacity", "0");
container.style.top = "0px";
container.style.top = `${STICKY_TOP}px`;
return;
}
const sentinelTop = sentinel.getBoundingClientRect().top;
@@ -838,7 +778,8 @@ const StickyUserMessage = memo<{
// correct height immediately when isStuck flips.
container.style.setProperty("--clip-h", `${fullHeight}px`);
container.style.setProperty("--fade-opacity", "0");
container.style.top = "0px";
container.style.top = `${STICKY_TOP}px`;
return;
}
const visible = Math.max(fullHeight - scrolledPast, MIN_HEIGHT);
@@ -865,9 +806,9 @@ const StickyUserMessage = memo<{
}
if (nextSentinel) {
const nextY = nextSentinel.getBoundingClientRect().top - scrollerTop;
container.style.top = `${Math.min(0, nextY - visible)}px`;
container.style.top = `${Math.min(STICKY_TOP, nextY - visible + STICKY_TOP)}px`;
} else {
container.style.top = "0px";
container.style.top = `${STICKY_TOP}px`;
}
};
updateFnRef.current = update;
@@ -962,7 +903,7 @@ const StickyUserMessage = memo<{
<div
ref={containerRef}
className={cn(
"relative px-3 -mx-3 -mt-3",
"relative px-3 -mx-3 -mt-2",
!isTooTall && "sticky z-10",
!isReady && "invisible",
isStuck && !isTooTall && "pointer-events-none",
@@ -1056,11 +997,7 @@ interface ConversationTimelineProps {
mcpServers?: readonly TypesGen.MCPServerConfig[];
computerUseSubagentIds?: Set<string>;
showDesktopPreviews?: boolean;
// When true the current turn is still in progress (the agent
// is streaming or a tool call is running). The copy button on
// the trailing assistant message is suppressed because the
// content is not yet final.
isTurnActive: boolean;
isTurnActive?: boolean;
}
export const ConversationTimeline = memo<ConversationTimelineProps>(
@@ -1074,7 +1011,6 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
mcpServers,
computerUseSubagentIds,
showDesktopPreviews,
isTurnActive,
}) => {
if (parsedMessages.length === 0) {
return null;
@@ -1097,22 +1033,10 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
}
return (
<div className="flex flex-col gap-3">
{(() => {
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 && !isTurnActive) {
lastAssistantPerTurnIds.add(lastAsstId);
}
return parsedMessages.map(({ message, parsed }, msgIdx) =>
message.role === "user" ? (
<div className="flex flex-col gap-2">
{parsedMessages.map(({ message, parsed }, msgIdx) => {
if (message.role === "user") {
return (
<StickyUserMessage
key={message.id}
message={message}
@@ -1122,24 +1046,28 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
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)}
isLastInConversation={msgIdx === parsedMessages.length - 1}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
/>
),
);
}
// Hide actions on assistant messages that are not
// the last in a consecutive assistant chain.
const next = parsedMessages[msgIdx + 1];
const isLastInChain = !next || next.message.role === "user";
return (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
hideActions={!isLastInChain}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
/>
);
})()}
})}
</div>
);
},
@@ -14,7 +14,6 @@ import {
import { ConversationTimeline } from "./ChatConversation/ConversationTimeline";
import { getLatestContextUsage } from "./ChatConversation/chatHelpers";
import {
isActiveChatStatus,
selectChatStatus,
selectHasStreamState,
selectMessagesByID,
@@ -66,9 +65,6 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
const chatStatus = useChatSelector(store, selectChatStatus);
const hasStreamState = useChatSelector(store, selectHasStreamState);
const isTurnActive = isActiveChatStatus(chatStatus) || hasStreamState;
const messages = orderedMessageIDs
.map((messageID) => messagesByID.get(messageID))
@@ -96,7 +92,6 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
mcpServers={mcpServers}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={false}
isTurnActive={isTurnActive}
/>
<LiveStreamTail
store={store}