Skip to content

Commit bd818af

Browse files
7418claude
andcommitted
feat: add copy button on hover for user and assistant messages
Shows a copy icon at the bottom of each message on hover. Clicking copies the text content to clipboard with a brief checkmark confirmation. User messages show the button right-aligned; assistant messages show it alongside the existing timestamp and token usage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 578762e commit bd818af

1 file changed

Lines changed: 37 additions & 7 deletions

File tree

src/components/chat/MessageItem.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { useState, useCallback } from 'react';
34
import type { Message, TokenUsage } from '@/types';
45
import {
56
Message as AIMessage,
@@ -13,6 +14,7 @@ import {
1314
ToolInput,
1415
ToolOutput,
1516
} from '@/components/ai-elements/tool';
17+
import { CopyIcon, CheckIcon } from 'lucide-react';
1618
import type { ToolUIPart } from 'ai';
1719

1820
interface MessageItemProps {
@@ -110,6 +112,35 @@ function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] {
110112
return 'output-available';
111113
}
112114

115+
function CopyButton({ text }: { text: string }) {
116+
const [copied, setCopied] = useState(false);
117+
118+
const handleCopy = useCallback(async () => {
119+
try {
120+
await navigator.clipboard.writeText(text);
121+
setCopied(true);
122+
setTimeout(() => setCopied(false), 2000);
123+
} catch {
124+
// fallback
125+
}
126+
}, [text]);
127+
128+
return (
129+
<button
130+
type="button"
131+
onClick={handleCopy}
132+
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
133+
title="Copy"
134+
>
135+
{copied ? (
136+
<CheckIcon className="h-3 w-3 text-green-500" />
137+
) : (
138+
<CopyIcon className="h-3 w-3" />
139+
)}
140+
</button>
141+
);
142+
}
143+
113144
function TokenUsageDisplay({ usage }: { usage: TokenUsage }) {
114145
const totalTokens = usage.input_tokens + usage.output_tokens;
115146
const costStr = usage.cost_usd !== undefined && usage.cost_usd !== null
@@ -182,13 +213,12 @@ export function MessageItem({ message }: MessageItemProps) {
182213
)}
183214
</MessageContent>
184215

185-
{/* Footer with timestamp and token usage - outside MessageContent to avoid overflow-hidden clipping */}
186-
{!isUser && (
187-
<div className="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
188-
<span className="text-xs text-muted-foreground/50">{timestamp}</span>
189-
{tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
190-
</div>
191-
)}
216+
{/* Footer with copy, timestamp and token usage */}
217+
<div className={`flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
218+
{!isUser && <span className="text-xs text-muted-foreground/50">{timestamp}</span>}
219+
{!isUser && tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
220+
{text && <CopyButton text={text} />}
221+
</div>
192222
</AIMessage>
193223
);
194224
}

0 commit comments

Comments
 (0)