From 6a682fae98a4357a362c2f483b71bf69aa2317aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 01:22:57 +0000 Subject: [PATCH] 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 --- docs/chat-ui-walkthrough.md | 28 ++++++++++ src/apps/officer-web/styles/prose.css | 10 ++++ .../src/apps/Chat/components/CodeBlock.tsx | 53 +++++++++++++++++++ .../apps/Chat/components/MessageBubble.tsx | 11 +++- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx diff --git a/docs/chat-ui-walkthrough.md b/docs/chat-ui-walkthrough.md index 0495f83b..7db9b08b 100644 --- a/docs/chat-ui-walkthrough.md +++ b/docs/chat-ui-walkthrough.md @@ -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 `` 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 - **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment diff --git a/src/apps/officer-web/styles/prose.css b/src/apps/officer-web/styles/prose.css index ff4b86ba..8b21b14c 100644 --- a/src/apps/officer-web/styles/prose.css +++ b/src/apps/officer-web/styles/prose.css @@ -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; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx b/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx new file mode 100644 index 00000000..0ad5efe2 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx @@ -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 + * `` 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(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 ( +
+
+        {children}
+      
+ +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index e63c541d..05a6c7c2 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -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) => { {injectImages(assistantText)} @@ -292,7 +297,11 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => { > {text ? (
- + {injectImages(text)}