authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 11:39:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-28 15:17:03-08:00
loge8896fe02d713d59c73b5212011507ef30dd9737
tree7cf6f46e013407cea5ec6a6445be9e0f6c689f4e
parente89b05326911562c8183d223df311ed0bebd3c25

Add copy button to chat markdown code blocks

- Wrap rendered markdown code blocks with a copy action button - Show copy/check icons with transient "Copied" state and cleanup - Style button to appear on hover/focus without shifting code layout

2 files changed, 102 insertions(+), 7 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+60-7
...@@ -1,10 +1,13 @@...@@ -1,10 +1,13 @@
1import { getSharedHighlighter } from "@pierre/diffs";1import { getSharedHighlighter } from "@pierre/diffs";
2import { CheckIcon, CopyIcon } from "lucide-react";
2import {3import {
3 Children,4 Children,
4 isValidElement,5 isValidElement,
6 useCallback,
5 memo,7 memo,
6 useEffect,8 useEffect,
7 useMemo,9 useMemo,
10 useRef,
8 useState,11 useState,
9 type ReactNode,12 type ReactNode,
10} from "react";13} from "react";
...@@ -159,6 +162,54 @@ interface ShikiCodeBlockProps {...@@ -159,6 +162,54 @@ interface ShikiCodeBlockProps {
159 fallback: ReactNode;162 fallback: ReactNode;
160}163}
161164
165function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) {
166 const [copied, setCopied] = useState(false);
167 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
168 const handleCopy = useCallback(() => {
169 if (typeof navigator === "undefined" || navigator.clipboard == null) {
170 return;
171 }
172 void navigator.clipboard
173 .writeText(code)
174 .then(() => {
175 if (copiedTimerRef.current != null) {
176 clearTimeout(copiedTimerRef.current);
177 }
178 setCopied(true);
179 copiedTimerRef.current = setTimeout(() => {
180 setCopied(false);
181 copiedTimerRef.current = null;
182 }, 1200);
183 })
184 .catch(() => undefined);
185 }, [code]);
186
187 useEffect(
188 () => () => {
189 if (copiedTimerRef.current != null) {
190 clearTimeout(copiedTimerRef.current);
191 copiedTimerRef.current = null;
192 }
193 },
194 [],
195 );
196
197 return (
198 <div className="chat-markdown-codeblock">
199 <button
200 type="button"
201 className="chat-markdown-copy-button"
202 onClick={handleCopy}
203 title={copied ? "Copied" : "Copy code"}
204 aria-label={copied ? "Copied" : "Copy code"}
205 >
206 {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
207 </button>
208 {children}
209 </div>
210 );
211}
212
162function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) {213function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) {
163 const language = extractFenceLanguage(className);214 const language = extractFenceLanguage(className);
164 const cacheKey = createHighlightCacheKey(code, language, themeName);215 const cacheKey = createHighlightCacheKey(code, language, themeName);
...@@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {...@@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
264 }315 }
265316
266 return (317 return (
267 <ShikiCodeBlock318 <MarkdownCodeBlock code={codeBlock.code}>
268 className={codeBlock.className}319 <ShikiCodeBlock
269 code={codeBlock.code}320 className={codeBlock.className}
270 themeName={diffThemeName}321 code={codeBlock.code}
271 isStreaming={isStreaming}322 themeName={diffThemeName}
272 fallback={<pre {...props}>{children}</pre>}323 isStreaming={isStreaming}
273 />324 fallback={<pre {...props}>{children}</pre>}
325 />
326 </MarkdownCodeBlock>
274 );327 );
275 },328 },
276 }),329 }),
apps/web/src/index.css+42
...@@ -314,6 +314,48 @@ label:has(> select#reasoning-effort) select {...@@ -314,6 +314,48 @@ label:has(> select#reasoning-effort) select {
314 font-size: 0.875rem;314 font-size: 0.875rem;
315}315}
316316
317.chat-markdown .chat-markdown-codeblock {
318 position: relative;
319 margin: 0.65rem 0;
320}
321
322.chat-markdown .chat-markdown-codeblock pre {
323 margin: 0;
324}
325
326.chat-markdown .chat-markdown-copy-button {
327 position: absolute;
328 top: 0.5rem;
329 right: 0.5rem;
330 z-index: 1;
331 display: inline-flex;
332 align-items: center;
333 justify-content: center;
334 height: 1.5rem;
335 width: 1.5rem;
336 border-radius: 0.375rem;
337 border: 1px solid var(--border);
338 background: color-mix(in srgb, var(--background) 82%, transparent);
339 color: var(--muted-foreground);
340 opacity: 0;
341 pointer-events: none;
342 transition:
343 opacity 120ms ease,
344 color 120ms ease,
345 border-color 120ms ease;
346}
347
348.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button,
349.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button {
350 opacity: 1;
351 pointer-events: auto;
352}
353
354.chat-markdown .chat-markdown-copy-button:hover {
355 color: var(--foreground);
356 border-color: color-mix(in srgb, var(--border) 70%, var(--foreground));
357}
358
317.chat-markdown .chat-markdown-shiki .shiki {359.chat-markdown .chat-markdown-shiki .shiki {
318 background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important;360 background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important;
319}361}