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 (
+
+ );
+});
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