From fb7aa922b9d2850ee61385b9ea0454b5c30275d4 Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Sun, 21 Dec 2025 10:33:06 +0100 Subject: [PATCH] feat: Add copy-to-clipboard buttons for code blocks and file paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add copy functionality to improve UX when working with code: - Code blocks: pill-style "Copy" button appears on hover (top-right) - File paths in Edit/Read/Write tools: icon button on hover - Long inline code (>40 chars): small icon button on hover Uses Clipboard API with visual feedback (green checkmark on success). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/CopyButton.jsx | 124 ++++++++++++++++++++ frontend/src/components/MessageList.jsx | 148 +++++++++++++++++++----- 2 files changed, 240 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/CopyButton.jsx diff --git a/frontend/src/components/CopyButton.jsx b/frontend/src/components/CopyButton.jsx new file mode 100644 index 0000000..52673ca --- /dev/null +++ b/frontend/src/components/CopyButton.jsx @@ -0,0 +1,124 @@ +import { memo, useState, useCallback } from 'react'; +import { Copy, Check } from 'lucide-react'; + +/** + * Reusable copy-to-clipboard button with feedback + * + * Variants: + * - "icon" (default): Small icon button, good for inline use + * - "pill": Pill-shaped button with text, good for code blocks + */ +export const CopyButton = memo(function CopyButton({ + text, + variant = 'icon', + className = '', + size = 'sm' // 'xs', 'sm', 'md' +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async (e) => { + e.stopPropagation(); + e.preventDefault(); + + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [text]); + + const sizeClasses = { + xs: 'w-3.5 h-3.5', + sm: 'w-4 h-4', + md: 'w-5 h-5', + }; + + const iconSize = sizeClasses[size] || sizeClasses.sm; + + if (variant === 'pill') { + return ( + + ); + } + + // Default: icon variant + return ( + + ); +}); + +/** + * Wrapper component that adds a copy button to any content + * Useful for wrapping code blocks, file paths, etc. + */ +export const CopyWrapper = memo(function CopyWrapper({ + text, + children, + className = '', + buttonPosition = 'top-right', // 'top-right', 'top-left', 'inline-right' + buttonVariant = 'icon', + showOnHover = true, +}) { + const positionClasses = { + 'top-right': 'absolute top-2 right-2', + 'top-left': 'absolute top-2 left-2', + 'inline-right': 'ml-2 inline-flex', + }; + + const isAbsolute = buttonPosition !== 'inline-right'; + + return ( +
+ {children} +
+ +
+
+ ); +}); diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index d868466..4ead612 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -5,7 +5,8 @@ import { User, Bot, Terminal, CheckCircle, AlertCircle, Info, FileText, Search, FolderSearch, Pencil, FilePlus, Globe, ChevronDown, ChevronRight, Play, ArrowDown, - ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command + ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command, + Copy, Check } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -22,6 +23,49 @@ const SyntaxHighlighter = lazy(() => // Import style separately (small JSON, OK to load eagerly for consistency) import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +// Copy button component for code blocks +const CopyButton = memo(function CopyButton({ text, variant = 'icon', className = '' }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async (e) => { + e.stopPropagation(); + e.preventDefault(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [text]); + + if (variant === 'pill') { + return ( + + ); + } + + return ( + + ); +}); + // Fallback component for code blocks while SyntaxHighlighter loads const CodeFallback = memo(function CodeFallback({ children }) { return ( @@ -31,19 +75,28 @@ const CodeFallback = memo(function CodeFallback({ children }) { ); }); -// Wrapper for lazy-loaded SyntaxHighlighter with Suspense -const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, ...props }) { +// Wrapper for lazy-loaded SyntaxHighlighter with Suspense and Copy button +const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, showCopy = true, ...props }) { + const codeString = String(children).replace(/\n$/, ''); + return ( - {children}}> - - {children} - - +
+ {showCopy && ( +
+ +
+ )} + {children}}> + + {codeString} + + +
); }); @@ -563,22 +616,44 @@ const Message = memo(function Message({ message, onSendMessage, hostConfig }) { components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); - return !inline && match ? ( - - {String(children).replace(/\n$/, '')} - - ) : ( + const codeString = String(children).replace(/\n$/, ''); + + // Block code with language + if (!inline && match) { + return ( + + {codeString} + + ); + } + + // Long inline code (>40 chars) - show copy button on hover + if (inline && codeString.length > 40) { + return ( + + + {children} + + + + + + ); + } + + // Regular inline code + return ( {children} @@ -957,9 +1032,12 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa return (
{/* File header */} -
+
File: {input.file_path} + + +
{/* Diff view */} @@ -1066,9 +1144,12 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa if (tool === 'Read') { return (
-
+
File: {input.file_path} + + +
{(input.offset || input.limit) && (
@@ -1197,9 +1278,12 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa return (
-
+
File: {input.file_path} + + + • {lines} lines