Skip to content

Commit fc5b70e

Browse files
authored
feat: add copy button to code blocks in agent output (#1598)
## Problem When the agent outputs code blocks (e.g. SQL queries, shell commands), there is no way to copy the code without manually selecting it. Fixes #1499 ## Solution Added a hover-to-reveal copy button to the `CodeBlock` component used by `MarkdownRenderer`. The button: - Appears in the top-right corner on hover (`group-hover`) - Uses the same `Copy`/`Check` icon pattern from `@phosphor-icons/react` already used in `AgentMessage.tsx` - Copies code text to clipboard via `navigator.clipboard.writeText` - Shows a ✓ checkmark for 2 seconds after copying - Uses `IconButton` from `@radix-ui/themes` for consistent styling ## Files Changed - `apps/code/src/renderer/components/CodeBlock.tsx` — wrapped in relative div with `group` class, added `IconButton` with copy logic
1 parent 1b60647 commit fc5b70e

File tree

1 file changed

+61
-18
lines changed

1 file changed

+61
-18
lines changed

apps/code/src/renderer/components/CodeBlock.tsx

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { Check, Copy } from "@phosphor-icons/react";
2+
import { IconButton } from "@radix-ui/themes";
13
import type { ReactNode } from "react";
4+
import { useCallback, useState } from "react";
25

36
type CodeBlockSize = "1" | "1.5" | "2" | "3";
47

@@ -29,27 +32,67 @@ const sizeStyles: Record<
2932
},
3033
};
3134

35+
function extractText(children: ReactNode): string {
36+
if (typeof children === "string") return children;
37+
if (Array.isArray(children)) return children.map(extractText).join("");
38+
if (children && typeof children === "object" && "props" in children) {
39+
return extractText(
40+
(children as { props: { children?: ReactNode } }).props.children,
41+
);
42+
}
43+
return "";
44+
}
45+
3246
export function CodeBlock({ children, size = "1" }: CodeBlockProps) {
3347
const styles = sizeStyles[size];
48+
const [copied, setCopied] = useState(false);
49+
50+
const handleCopy = useCallback(() => {
51+
const text = extractText(children);
52+
navigator.clipboard.writeText(text);
53+
setCopied(true);
54+
setTimeout(() => setCopied(false), 2000);
55+
}, [children]);
3456

3557
return (
36-
<pre
37-
style={{
38-
margin: 0,
39-
marginBottom: "var(--space-3)",
40-
padding: "var(--space-3)",
41-
backgroundColor: "var(--gray-2)",
42-
borderRadius: "var(--radius-2)",
43-
border: "1px solid var(--gray-4)",
44-
fontFamily: "var(--code-font-family)",
45-
fontSize: styles.fontSize,
46-
lineHeight: styles.lineHeight,
47-
color: "var(--gray-12)",
48-
overflowX: "auto",
49-
whiteSpace: "pre",
50-
}}
51-
>
52-
{children}
53-
</pre>
58+
<div className="group" style={{ position: "relative" }}>
59+
<pre
60+
style={{
61+
margin: 0,
62+
marginBottom: "var(--space-3)",
63+
padding: "var(--space-3)",
64+
paddingRight: "var(--space-7)",
65+
backgroundColor: "var(--gray-2)",
66+
borderRadius: "var(--radius-2)",
67+
border: "1px solid var(--gray-4)",
68+
fontFamily: "var(--code-font-family)",
69+
fontSize: styles.fontSize,
70+
lineHeight: styles.lineHeight,
71+
color: "var(--gray-12)",
72+
overflowX: "auto",
73+
whiteSpace: "pre",
74+
}}
75+
>
76+
{children}
77+
</pre>
78+
<IconButton
79+
size="1"
80+
variant="ghost"
81+
color="gray"
82+
onClick={handleCopy}
83+
style={{
84+
position: "absolute",
85+
top: "var(--space-1)",
86+
right: "var(--space-1)",
87+
opacity: 0,
88+
transition: "opacity 0.15s",
89+
cursor: "pointer",
90+
}}
91+
className="group-hover:!opacity-100 [&]:hover:!opacity-100"
92+
aria-label="Copy code"
93+
>
94+
{copied ? <Check size={14} /> : <Copy size={14} />}
95+
</IconButton>
96+
</div>
5497
);
5598
}

0 commit comments

Comments
 (0)