copy button on every code block
the bubble's copy button copies the whole reply, which is the wrong unit when the reply is prose ending in one command to run. fenced blocks get their own button; inline code doesn't. text read from textContent at click time rather than the markdown ast, trailing newline stripped so a pasted command doesn't run itself. the positioned wrapper takes the vertical margin, or the pre's own margin collapses through it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,16 @@
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* A fenced block is wrapped so its copy button has something to position against. The wrapper carries
|
||||
the spacing, or the pre's own margin would collapse through it and defeat the first/last-child reset. */
|
||||
.chat-md .chat-code {
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.chat-md .chat-code pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-md pre {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.75em 1em;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* A fenced code block with its own copy button.
|
||||
*
|
||||
* The bubble already had one, but it copies the entire reply. When the reply is prose ending in a
|
||||
* command you are meant to run, that is the wrong unit — you end up selecting the line by hand, which is
|
||||
* exactly the thing a command in a chat exists to save you from.
|
||||
*
|
||||
* The text comes from the rendered DOM at click time rather than the markdown AST: `children` here is a
|
||||
* `<code>` element whose own children are strings, elements or nested arrays depending on which plugins
|
||||
* ran, and reassembling that is guesswork. `textContent` is precisely what is on screen. Only fenced
|
||||
* blocks get a button — inline code is short enough to select, and a button per `` `word` `` would be
|
||||
* noise.
|
||||
*/
|
||||
export const CodeBlock = ({ children, ...props }: ComponentPropsWithoutRef<'pre'>) => {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
// The trailing newline is part of the fence, not the command — pasting it into a shell runs it.
|
||||
const text = ref.current?.textContent?.replace(/\n+$/, '') ?? '';
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* no clipboard permission — the text is still selectable */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-code group/code relative">
|
||||
<pre ref={ref} {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title="Copy code"
|
||||
aria-label="Copy code"
|
||||
// Hover-revealed on a pointer device, always visible on touch — there is no hover to reveal it
|
||||
// with. Colours are hardcoded light-on-dark because the block behind it is #0d1117 in both themes.
|
||||
className="absolute top-1.5 right-1.5 cursor-pointer rounded border border-white/15 bg-white/10 p-1 text-white/80 opacity-70 transition-opacity hover:!opacity-100 focus-visible:opacity-100 md:opacity-0 md:group-hover/code:opacity-70"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,10 @@ import { QuestionActivity } from './QuestionActivity';
|
||||
import { getRawUrl } from '../../FileViewer/file-types';
|
||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
|
||||
/** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
|
||||
const MD_COMPONENTS = { pre: CodeBlock };
|
||||
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
@@ -176,6 +180,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{injectImages(assistantText)}
|
||||
</ReactMarkdown>
|
||||
@@ -292,7 +297,11 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
>
|
||||
{text ? (
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{injectImages(text)}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
|
||||
|
||||
Reference in New Issue
Block a user