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 @@
11import { getSharedHighlighter } from "@pierre/diffs";
2import { CheckIcon, CopyIcon } from "lucide-react";
23import {
34 Children,
45 isValidElement,
6 useCallback,
57 memo,
68 useEffect,
79 useMemo,
10 useRef,
811 useState,
912 type ReactNode,
1013} from "react";
......@@ -159,6 +162,54 @@ interface ShikiCodeBlockProps {
159162 fallback: ReactNode;
160163}
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
162213function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) {
163214 const language = extractFenceLanguage(className);
164215 const cacheKey = createHighlightCacheKey(code, language, themeName);
......@@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
264315 }
265316
266317 return (
267 <ShikiCodeBlock
268 className={codeBlock.className}
269 code={codeBlock.code}
270 themeName={diffThemeName}
271 isStreaming={isStreaming}
272 fallback={<pre {...props}>{children}</pre>}
273 />
318 <MarkdownCodeBlock code={codeBlock.code}>
319 <ShikiCodeBlock
320 className={codeBlock.className}
321 code={codeBlock.code}
322 themeName={diffThemeName}
323 isStreaming={isStreaming}
324 fallback={<pre {...props}>{children}</pre>}
325 />
326 </MarkdownCodeBlock>
274327 );
275328 },
276329 }),
apps/web/src/index.css+42
......@@ -314,6 +314,48 @@ label:has(> select#reasoning-effort) select {
314314 font-size: 0.875rem;
315315}
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
317359.chat-markdown .chat-markdown-shiki .shiki {
318360 background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important;
319361}