From aa0e288b88052afc656a2d3cf8457fb38c8955e2 Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 2 Apr 2026 16:33:09 -0700 Subject: [PATCH] fix(site): improve assistant message copy button UX (#23996) Improves the copy button on the last assistant message in agent chat conversations. **Changes:** - Copy icon aligned to the left edge of the content area - No margin-top gap between message content and the copy button - Hovering the copy button reveals a 2px vertical indicator line spanning the copyable content - Tooltip repositioned to bottom-left with compact sizing - Extra padding below the copy button to visually separate from the next user prompt - Hover state triggers only on the button itself, not the entire row --- .../ChatConversation/ConversationTimeline.tsx | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 7caff5e8ed..02a5f21853 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1,4 +1,4 @@ -import { FileTextIcon, PencilIcon } from "lucide-react"; +import { CopyIcon, FileTextIcon, PencilIcon } from "lucide-react"; import { type FC, Fragment, @@ -10,6 +10,8 @@ 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"; import { @@ -17,6 +19,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { useClipboard } from "#/hooks/useClipboard"; import { cn } from "#/utils/cn"; import { decodeInlineTextAttachment, @@ -437,6 +440,7 @@ const ChatMessageItem = memo<{ savingMessageId?: number | null; isAfterEditingMessage?: boolean; isLastAssistantMessage?: boolean; + isLastInConversation?: 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. @@ -455,6 +459,7 @@ const ChatMessageItem = memo<{ savingMessageId, isAfterEditingMessage = false, isLastAssistantMessage = false, + isLastInConversation = false, fadeFromBottom = false, urlTransform, mcpServers, @@ -466,6 +471,8 @@ const ChatMessageItem = memo<{ const isSavingMessage = savingMessageId === message.id; const [previewImage, setPreviewImage] = useState(null); const [previewText, setPreviewText] = useState(null); + const [copyHovered, setCopyHovered] = useState(false); + const { showCopiedSuccess, copyToClipboard } = useClipboard(); if ( parsed.toolResults.length > 0 && parsed.toolCalls.length === 0 && @@ -612,9 +619,26 @@ const ChatMessageItem = memo<{ ) : ( - + -
+
- + + + + + + {showCopiedSuccess ? "Copied!" : "Copy message"} + +
) : undefined } @@ -1064,7 +1111,7 @@ export const ConversationTimeline = memo( if (lastAsstId != null && !isTurnActive) { lastAssistantPerTurnIds.add(lastAsstId); } - return parsedMessages.map(({ message, parsed }) => + return parsedMessages.map(({ message, parsed }, msgIdx) => message.role === "user" ? ( ( urlTransform={urlTransform} isAfterEditingMessage={afterEditingMessageIds.has(message.id)} isLastAssistantMessage={lastAssistantPerTurnIds.has(message.id)} + isLastInConversation={msgIdx === parsedMessages.length - 1} mcpServers={mcpServers} subagentTitles={subagentTitles} computerUseSubagentIds={computerUseSubagentIds}