feat: Add copy-to-clipboard buttons for code blocks and file paths
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 <noreply@anthropic.com>
This commit is contained in:
124
frontend/src/components/CopyButton.jsx
Normal file
124
frontend/src/components/CopyButton.jsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium
|
||||||
|
transition-all duration-200
|
||||||
|
${copied
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-dark-700 hover:bg-dark-600 text-dark-400 hover:text-dark-200'
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
<span>Copied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
<span>Copy</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: icon variant
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`
|
||||||
|
p-1 rounded transition-all duration-200
|
||||||
|
${copied
|
||||||
|
? 'text-green-400 bg-green-500/20'
|
||||||
|
: 'text-dark-500 hover:text-dark-300 hover:bg-dark-700'
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className={iconSize} />
|
||||||
|
) : (
|
||||||
|
<Copy className={iconSize} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className={`${isAbsolute ? 'relative group' : 'inline-flex items-center'} ${className}`}>
|
||||||
|
{children}
|
||||||
|
<div className={`
|
||||||
|
${positionClasses[buttonPosition]}
|
||||||
|
${showOnHover && isAbsolute ? 'opacity-0 group-hover:opacity-100 transition-opacity duration-200' : ''}
|
||||||
|
`}>
|
||||||
|
<CopyButton text={text} variant={buttonVariant} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
|
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
|
||||||
FileText, Search, FolderSearch, Pencil, FilePlus, Globe,
|
FileText, Search, FolderSearch, Pencil, FilePlus, Globe,
|
||||||
ChevronDown, ChevronRight, Play, ArrowDown,
|
ChevronDown, ChevronRight, Play, ArrowDown,
|
||||||
ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command
|
ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command,
|
||||||
|
Copy, Check
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -22,6 +23,49 @@ const SyntaxHighlighter = lazy(() =>
|
|||||||
// Import style separately (small JSON, OK to load eagerly for consistency)
|
// Import style separately (small JSON, OK to load eagerly for consistency)
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all duration-200
|
||||||
|
${copied ? 'bg-green-500/20 text-green-400' : 'bg-dark-700 hover:bg-dark-600 text-dark-400 hover:text-dark-200'}
|
||||||
|
${className}`}
|
||||||
|
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||||
|
>
|
||||||
|
{copied ? <><Check className="w-3.5 h-3.5" /><span>Copied</span></> : <><Copy className="w-3.5 h-3.5" /><span>Copy</span></>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`p-1 rounded transition-all duration-200
|
||||||
|
${copied ? 'text-green-400 bg-green-500/20' : 'text-dark-500 hover:text-dark-300 hover:bg-dark-700'}
|
||||||
|
${className}`}
|
||||||
|
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Fallback component for code blocks while SyntaxHighlighter loads
|
// Fallback component for code blocks while SyntaxHighlighter loads
|
||||||
const CodeFallback = memo(function CodeFallback({ children }) {
|
const CodeFallback = memo(function CodeFallback({ children }) {
|
||||||
return (
|
return (
|
||||||
@@ -31,19 +75,28 @@ const CodeFallback = memo(function CodeFallback({ children }) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrapper for lazy-loaded SyntaxHighlighter with Suspense
|
// Wrapper for lazy-loaded SyntaxHighlighter with Suspense and Copy button
|
||||||
const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, ...props }) {
|
const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, showCopy = true, ...props }) {
|
||||||
|
const codeString = String(children).replace(/\n$/, '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<CodeFallback>{children}</CodeFallback>}>
|
<div className="relative group">
|
||||||
<SyntaxHighlighter
|
{showCopy && (
|
||||||
language={language}
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
|
||||||
style={style}
|
<CopyButton text={codeString} variant="pill" />
|
||||||
customStyle={customStyle}
|
</div>
|
||||||
{...props}
|
)}
|
||||||
>
|
<Suspense fallback={<CodeFallback>{children}</CodeFallback>}>
|
||||||
{children}
|
<SyntaxHighlighter
|
||||||
</SyntaxHighlighter>
|
language={language}
|
||||||
</Suspense>
|
style={style}
|
||||||
|
customStyle={customStyle}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{codeString}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -563,22 +616,44 @@ const Message = memo(function Message({ message, onSendMessage, hostConfig }) {
|
|||||||
components={{
|
components={{
|
||||||
code({ node, inline, className, children, ...props }) {
|
code({ node, inline, className, children, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
return !inline && match ? (
|
const codeString = String(children).replace(/\n$/, '');
|
||||||
<LazyCodeBlock
|
|
||||||
style={oneDark}
|
// Block code with language
|
||||||
language={match[1]}
|
if (!inline && match) {
|
||||||
PreTag="div"
|
return (
|
||||||
customStyle={{
|
<LazyCodeBlock
|
||||||
margin: 0,
|
style={oneDark}
|
||||||
borderRadius: '0.5rem',
|
language={match[1]}
|
||||||
fontSize: '0.75rem',
|
PreTag="div"
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
customStyle={{
|
||||||
}}
|
margin: 0,
|
||||||
{...props}
|
borderRadius: '0.5rem',
|
||||||
>
|
fontSize: '0.75rem',
|
||||||
{String(children).replace(/\n$/, '')}
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
</LazyCodeBlock>
|
}}
|
||||||
) : (
|
{...props}
|
||||||
|
>
|
||||||
|
{codeString}
|
||||||
|
</LazyCodeBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long inline code (>40 chars) - show copy button on hover
|
||||||
|
if (inline && codeString.length > 40) {
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex items-center group/code">
|
||||||
|
<code className={`${className || ''} bg-dark-800 px-1.5 py-0.5 rounded text-orange-300`} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
<span className="opacity-0 group-hover/code:opacity-100 transition-opacity duration-200 ml-1">
|
||||||
|
<CopyButton text={codeString} size="xs" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular inline code
|
||||||
|
return (
|
||||||
<code className={className} {...props}>
|
<code className={className} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
@@ -957,9 +1032,12 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* File header */}
|
{/* File header */}
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs group/path">
|
||||||
<span className="text-dark-500">File:</span>
|
<span className="text-dark-500">File:</span>
|
||||||
<code className="text-orange-400">{input.file_path}</code>
|
<code className="text-orange-400">{input.file_path}</code>
|
||||||
|
<span className="opacity-0 group-hover/path:opacity-100 transition-opacity">
|
||||||
|
<CopyButton text={input.file_path} size="xs" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diff view */}
|
{/* Diff view */}
|
||||||
@@ -1066,9 +1144,12 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa
|
|||||||
if (tool === 'Read') {
|
if (tool === 'Read') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 group/path">
|
||||||
<span className="text-xs text-dark-500">File:</span>
|
<span className="text-xs text-dark-500">File:</span>
|
||||||
<code className="text-xs text-blue-400">{input.file_path}</code>
|
<code className="text-xs text-blue-400">{input.file_path}</code>
|
||||||
|
<span className="opacity-0 group-hover/path:opacity-100 transition-opacity">
|
||||||
|
<CopyButton text={input.file_path} size="xs" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(input.offset || input.limit) && (
|
{(input.offset || input.limit) && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -1197,9 +1278,12 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs group/path">
|
||||||
<span className="text-dark-500">File:</span>
|
<span className="text-dark-500">File:</span>
|
||||||
<code className="text-green-400">{input.file_path}</code>
|
<code className="text-green-400">{input.file_path}</code>
|
||||||
|
<span className="opacity-0 group-hover/path:opacity-100 transition-opacity">
|
||||||
|
<CopyButton text={input.file_path} size="xs" />
|
||||||
|
</span>
|
||||||
<span className="text-dark-600">•</span>
|
<span className="text-dark-600">•</span>
|
||||||
<span className="text-dark-500">{lines} lines</span>
|
<span className="text-dark-500">{lines} lines</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user