From e8896fe02d713d59c73b5212011507ef30dd9737 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 11:39:36 -0800 Subject: [PATCH] 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 --- apps/web/src/components/ChatMarkdown.tsx | 67 +++++++++++++++++++++--- apps/web/src/index.css | 42 +++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 55ee4483843e36b4ae7ad4a5272975791171a0d4..ae82b805d67c05bd40aa6146184b953e2b997b55 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,10 +1,13 @@ import { getSharedHighlighter } from "@pierre/diffs"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { Children, isValidElement, + useCallback, memo, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -159,6 +162,54 @@ interface ShikiCodeBlockProps { fallback: ReactNode; } +function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return; + } + void navigator.clipboard + .writeText(code) + .then(() => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 1200); + }) + .catch(() => undefined); + }, [code]); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + }, + [], + ); + + return ( +
+ + {children} +
+ ); +} + function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); @@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { } return ( - {children}} - /> + + {children}} + /> + ); }, }), diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3cdadb01fd4997784cdabae9dce9453b2041b65c..e752f8ef658c671c415f4208d0500a27a05126a0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -314,6 +314,48 @@ label:has(> select#reasoning-effort) select { font-size: 0.875rem; } +.chat-markdown .chat-markdown-codeblock { + position: relative; + margin: 0.65rem 0; +} + +.chat-markdown .chat-markdown-codeblock pre { + margin: 0; +} + +.chat-markdown .chat-markdown-copy-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.5rem; + width: 1.5rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--background) 82%, transparent); + color: var(--muted-foreground); + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button, +.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button { + opacity: 1; + pointer-events: auto; +} + +.chat-markdown .chat-markdown-copy-button:hover { + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 70%, var(--foreground)); +} + .chat-markdown .chat-markdown-shiki .shiki { background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; } -- 2.54.0