diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 0cae828d543..66811fe99ac 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -366,6 +366,21 @@ describe("MarkdownBody", () => { expect(html).toContain('style="max-width:100%;overflow-x:auto"'); }); + it("renders a copy button alongside fenced code blocks", () => { + const html = renderMarkdown("```ts\nconst a = 1;\n```"); + + expect(html).toContain("paperclip-markdown-codeblock"); + expect(html).toContain("paperclip-markdown-codeblock-copy"); + expect(html).toContain('aria-label="Copy code"'); + expect(html).toContain("lucide-copy"); + }); + + it("does not render a copy button on inline code", () => { + const html = renderMarkdown("Reference `inline-code` here."); + + expect(html).not.toContain("paperclip-markdown-codeblock-copy"); + }); + it("renders internal issue links and bare identifiers as inline issue refs", () => { const html = renderMarkdown(`See PAP-42 and [linked task](${buildIssueReferenceHref("PAP-77")}) for follow-up.`, [ { identifier: "PAP-42", status: "done" }, diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index b155032f788..c6082e6d234 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ -import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; +import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { ExternalLink, Github } from "lucide-react"; +import { Check, Copy, ExternalLink, Github } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -183,6 +183,80 @@ function renderLinkBody( ); } +function CodeBlock({ + children, + preProps, +}: { + children: ReactNode; + preProps: React.HTMLAttributes; +}) { + const [copied, setCopied] = useState(false); + const [failed, setFailed] = useState(false); + const preRef = useRef(null); + const timerRef = useRef>(undefined); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback(async () => { + const text = preRef.current?.innerText ?? flattenText(children); + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + try { + textarea.select(); + const success = document.execCommand("copy"); + if (!success) throw new Error("execCommand copy failed"); + } finally { + document.body.removeChild(textarea); + } + } + setFailed(false); + setCopied(true); + } catch { + setFailed(true); + setCopied(true); + } + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + }, [children]); + + const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; + + return ( +
+
+        {children}
+      
+ +
+ ); +} + function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -286,7 +360,7 @@ export function MarkdownBody({ if (mermaidSource) { return ; } - return
{preChildren}
; + return {preChildren}; }, code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => ( diff --git a/ui/src/index.css b/ui/src/index.css index 8fe5185ec79..1390e14bdba 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -670,6 +670,53 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background: none; } +/* Copy-to-clipboard button on fenced code blocks */ +.paperclip-markdown-codeblock { + position: relative; +} + +.paperclip-markdown-codeblock-copy { + position: absolute; + top: 0.4rem; + right: 0.4rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.4rem; + border-radius: calc(var(--radius) - 4px); + border: 1px solid #ffffff26; + background-color: #2a2a3e; + color: #cdd6f4; + font-size: 0.7rem; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease; +} + +.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy, +.paperclip-markdown-codeblock-copy:focus-visible, +.paperclip-markdown-codeblock-copy[data-copied] { + opacity: 1; +} + +.paperclip-markdown-codeblock-copy:hover { + background-color: #3a3a52; + color: #ffffff; +} + +.paperclip-markdown-codeblock-copy[data-copied] { + color: #a6e3a1; +} + +.paperclip-markdown-codeblock-copy[data-failed] { + color: #f38ba8; +} + +.paperclip-markdown-codeblock-copy-label { + font-weight: 500; +} + /* Remove backtick pseudo-elements from inline code (prose default adds them) */ .prose code::before, .prose code::after {