feat: add copy button on code blocks

Hover over any fenced code block to reveal a floating copy-to-clipboard
button (top-right corner). Provides visual feedback (checkmark) on success.
Uses a custom ReactMarkdown <pre> component wrapper.
This commit is contained in:
Nicolas Varrot
2026-02-11 15:46:49 +00:00
parent 8af812807a
commit b6a989bb51
2 changed files with 43 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
import { ThinkingBlock } from './ThinkingBlock';
import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall';
import { Bot, User, Wrench } from 'lucide-react';
import { t, locale } from '../lib/i18n';
@@ -127,10 +128,12 @@ function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] {
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
}
const markdownComponents = { pre: CodeBlock };
function renderTextBlocks(blocks: MessageBlock[]) {
return getTextBlocks(blocks).map((block, i) => (
<div key={`text-${i}`} className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
{autoFormatText((block as any).text)}
</ReactMarkdown>
</div>
@@ -220,7 +223,7 @@ export function ChatMessageComponent({ message }: { message: ChatMessageType })
{/* User-visible text */}
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
{autoFormatText(message.content)}
</ReactMarkdown>
</div>

View File

@@ -0,0 +1,38 @@
import { useState, useCallback, type HTMLAttributes } from 'react';
import { Check, Copy } from 'lucide-react';
/**
* Custom <pre> renderer for ReactMarkdown.
* Wraps code blocks with a floating copy button.
*/
export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
// Extract text from the nested <code> element
const code = (props.children as any)?.props?.children;
if (typeof code === 'string') {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
}, [props.children]);
return (
<div className="group/code relative">
<pre {...props} />
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-zinc-700/60 hover:bg-zinc-600/80 border border-white/10 text-zinc-400 hover:text-zinc-200 opacity-0 group-hover/code:opacity-100 transition-opacity duration-150"
title="Copy code"
type="button"
>
{copied
? <Check className="h-3.5 w-3.5 text-green-400" />
: <Copy className="h-3.5 w-3.5" />
}
</button>
</div>
);
}