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:
2026-08-07 01:22:57 +00:00
co-authored by Claude Opus 5
parent 29fc9722c1
commit 6a682fae98
4 changed files with 101 additions and 1 deletions
+28
View File
@@ -491,6 +491,34 @@ reasoned, not observed. Typecheck and the sidecar tests are clean.
--- ---
## 18. Every code block has its own copy button
**Where:** any reply containing a fenced block — a command to run, a snippet to paste.
The bubble's copy button copies the _whole reply_. When the reply is prose ending in one command you're
meant to run, that's the wrong unit, and you end up selecting the line by hand — the one chore a command
in a chat exists to save you. Fenced blocks now carry a button in their top-right corner. Hover-revealed
on a pointer device, always visible on touch, because there is no hover there to reveal it with.
Inline `` `code` `` deliberately gets nothing: it's short enough to select, and a button per backticked
word would be noise.
Two details. The text is read from the rendered DOM (`textContent`) at click time rather than
reconstructed from the markdown AST — react-markdown hands the `pre` override a `<code>` element whose
children are strings, elements or nested arrays depending on which plugins ran, and reassembling that is
guesswork; `textContent` is exactly what's on screen. And the trailing newline is stripped, because it
belongs to the fence, not the command — pasted into a shell it would _run_ the thing rather than leave it
on the prompt for you to look at.
The block is wrapped in a positioned div, so `prose.css` moved the vertical margin onto the wrapper;
otherwise the `pre`'s own margin collapses through it and the `:first-child`/`:last-child` reset stops
working. The streaming bubble gets the same treatment, so a block doesn't gain a button when the turn
ends.
**Not verified:** the browser.
---
## Things noticed and deliberately left alone ## Things noticed and deliberately left alone
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment - **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
+10
View File
@@ -130,6 +130,16 @@
font-family: ui-monospace, monospace; 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 { .chat-md pre {
margin: 0.75em 0; margin: 0.75em 0;
padding: 0.75em 1em; 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 { getRawUrl } from '../../FileViewer/file-types';
import { useFilesAPI } from '../../../hooks/useFilesAPI'; import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { CopyButton } from './CopyButton'; 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 = { const sanitizeSchema = {
...defaultSchema, ...defaultSchema,
@@ -176,6 +180,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
> >
{injectImages(assistantText)} {injectImages(assistantText)}
</ReactMarkdown> </ReactMarkdown>
@@ -292,7 +297,11 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
> >
{text ? ( {text ? (
<div className="chat-md"> <div className="chat-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}> <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
{injectImages(text)} {injectImages(text)}
</ReactMarkdown> </ReactMarkdown>
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" /> <span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />