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