feat: add copy-to-clipboard button on assistant messages

Appears on hover over assistant message bubbles. Shows a check icon
with 'Copied!' feedback for 2s after clicking. i18n support (EN/FR).
Does not show on streaming messages or empty messages.
This commit is contained in:
Nicolas Varrot
2026-02-11 19:26:06 +00:00
parent cbee0efcee
commit dd5b56e02c
2 changed files with 43 additions and 2 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
@@ -7,7 +8,7 @@ import { ThinkingBlock } from './ThinkingBlock';
import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall';
import { ImageBlock, buildImageSrc } from './ImageBlock';
import { Bot, User, Wrench } from 'lucide-react';
import { Bot, User, Wrench, Copy, Check } from 'lucide-react';
import { t, getLocale } from '../lib/i18n';
import { useLocale } from '../hooks/useLocale';
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
@@ -212,6 +213,35 @@ function InternalOnlyMessage({ message }: { message: ChatMessageType }) {
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [text]);
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 h-7 w-7 rounded-lg border border-white/8 bg-zinc-800/80 backdrop-blur-sm flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:border-cyan-400/30 transition-all opacity-0 group-hover:opacity-100"
title={copied ? t('message.copied') : t('message.copy')}
aria-label={t('message.copy')}
>
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
</button>
);
}
/** Extract plain text from message blocks for clipboard copy */
function getPlainText(message: ChatMessageType): string {
if (message.blocks.length > 0) {
return getTextBlocks(message.blocks).map(b => (b as any).text).join('\n\n');
}
return message.content;
}
export function ChatMessageComponent({ message }: { message: ChatMessageType }) {
useLocale(); // re-render on locale change
const isUser = message.role === 'user';
@@ -238,11 +268,15 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
{/* Bubble */}
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
<div className={`inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed border border-white/8 max-w-full overflow-hidden ${
<div className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed border border-white/8 max-w-full overflow-hidden ${
isUser
? 'bg-gradient-to-b from-zinc-800/70 to-zinc-900/70 text-zinc-200'
: 'bg-zinc-800/40 text-zinc-300 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
}`}>
{/* Copy button (assistant messages only) */}
{!isUser && !message.isStreaming && getPlainText(message).trim() && (
<CopyButton text={getPlainText(message)} />
)}
{/* User-visible text */}
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
<div className="markdown-body">

View File

@@ -54,6 +54,10 @@ const en = {
'connection.reconnecting': 'Connection lost — reconnecting…',
'connection.reconnected': 'Reconnected!',
// Message actions
'message.copy': 'Copy message',
'message.copied': 'Copied!',
// Timestamps
'time.yesterday': 'Yesterday',
} as const;
@@ -98,6 +102,9 @@ const fr: Record<keyof typeof en, string> = {
'connection.reconnecting': 'Connexion perdue — reconnexion…',
'connection.reconnected': 'Reconnecté !',
'message.copy': 'Copier le message',
'message.copied': 'Copié !',
'time.yesterday': 'Hier',
};