From dd5b56e02ce88d9bb0087a1ede074e01fc96d031 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Wed, 11 Feb 2026 19:26:06 +0000 Subject: [PATCH] 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. --- src/components/ChatMessage.tsx | 38 ++++++++++++++++++++++++++++++++-- src/lib/i18n.ts | 7 +++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index dc1c9e4..0f61cd9 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -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 ( + + ); +} + +/** 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 */}
-
+ {/* Copy button (assistant messages only) */} + {!isUser && !message.isStreaming && getPlainText(message).trim() && ( + + )} {/* User-visible text */} {message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 1ad43cc..79391b2 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -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 = { 'connection.reconnecting': 'Connexion perdue — reconnexion…', 'connection.reconnected': 'Reconnecté !', + 'message.copy': 'Copier le message', + 'message.copied': 'Copié !', + 'time.yesterday': 'Hier', };