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)}