mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
@@ -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<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 &&
|
||||
@@ -612,9 +619,26 @@ const ChatMessageItem = memo<{
|
||||
</MessageContent>
|
||||
</Message>
|
||||
) : (
|
||||
<Message className="w-full">
|
||||
<Message
|
||||
className={cn(
|
||||
"w-full",
|
||||
isLastAssistantMessage &&
|
||||
hasCopyableContent &&
|
||||
!isLastInConversation &&
|
||||
"pb-5",
|
||||
)}
|
||||
>
|
||||
<MessageContent className="whitespace-normal">
|
||||
<div className="space-y-3">
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<BlockList
|
||||
blocks={parsed.blocks}
|
||||
tools={parsed.tools}
|
||||
@@ -629,13 +653,36 @@ const ChatMessageItem = memo<{
|
||||
afterResponseSlot={
|
||||
hasCopyableContent && isLastAssistantMessage ? (
|
||||
<div
|
||||
className="flex"
|
||||
className="flex !mt-0 -ml-2.5"
|
||||
data-testid="assistant-copy-button"
|
||||
>
|
||||
<CopyButton
|
||||
text={parsed.markdown}
|
||||
label="Copy message"
|
||||
/>
|
||||
<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
|
||||
}
|
||||
@@ -1064,7 +1111,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
if (lastAsstId != null && !isTurnActive) {
|
||||
lastAssistantPerTurnIds.add(lastAsstId);
|
||||
}
|
||||
return parsedMessages.map(({ message, parsed }) =>
|
||||
return parsedMessages.map(({ message, parsed }, msgIdx) =>
|
||||
message.role === "user" ? (
|
||||
<StickyUserMessage
|
||||
key={message.id}
|
||||
@@ -1084,6 +1131,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
urlTransform={urlTransform}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
|
||||
isLastAssistantMessage={lastAssistantPerTurnIds.has(message.id)}
|
||||
isLastInConversation={msgIdx === parsedMessages.length - 1}
|
||||
mcpServers={mcpServers}
|
||||
subagentTitles={subagentTitles}
|
||||
computerUseSubagentIds={computerUseSubagentIds}
|
||||
|
||||
Reference in New Issue
Block a user