Skip to content

Commit b8eb80c

Browse files
authored
feat(chat): add copy buttons to code blocks and agent prose messages (#3238)
1 parent 0f5a1d9 commit b8eb80c

3 files changed

Lines changed: 106 additions & 18 deletions

File tree

packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Check, Copy } from "@phosphor-icons/react";
12
import {
23
Heading,
34
Separator,
@@ -14,11 +15,41 @@ import {
1415
splitMarkdownBlocks,
1516
} from "@posthog/ui/features/editor/components/splitMarkdownBlocks";
1617
import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode";
17-
import { memo, useMemo } from "react";
18+
import { useCopy } from "@posthog/ui/primitives/useCopy";
19+
import { IconButton } from "@radix-ui/themes";
20+
import { memo, type ReactNode, useMemo } from "react";
1821
import Markdown, { type Components } from "react-markdown";
1922
import rehypeSanitize from "rehype-sanitize";
2023
import remarkGfm from "remark-gfm";
2124

25+
function ChatCodeBlock({
26+
code,
27+
children,
28+
}: {
29+
code: string;
30+
children: ReactNode;
31+
}) {
32+
const { copied, copy } = useCopy();
33+
34+
return (
35+
<div className="group relative">
36+
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/50 p-3 pr-10 text-sm leading-[1.5]">
37+
{children}
38+
</pre>
39+
<IconButton
40+
size="1"
41+
variant="ghost"
42+
color={copied ? "green" : "gray"}
43+
onClick={() => copy(code)}
44+
className="absolute top-1 right-1 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
45+
aria-label="Copy code"
46+
>
47+
{copied ? <Check size={14} /> : <Copy size={14} />}
48+
</IconButton>
49+
</div>
50+
);
51+
}
52+
2253
/**
2354
* The chat thread's own markdown renderer — intentionally separate from the app-wide
2455
* `MarkdownRenderer` (which carries PostHog deeplink handling, Radix Text wrappers, and other
@@ -47,16 +78,24 @@ const components: Components = {
4778
),
4879
li: ({ children }) => <li className="text-sm">{children}</li>,
4980
code: ({ className, children }) => {
81+
const text = String(children).replace(/\n$/, "");
5082
const match = /language-(\w+)/.exec(className ?? "");
51-
if (match) {
52-
// Fenced block with a language → Shiki-highlighted (theme-aware). The `pre` renderer
53-
// below provides the box; HighlightedCode renders the colored <code> inside it.
83+
// Fenced blocks (carry a language, or span multiple lines) render as a boxed, copyable
84+
// block; short inline spans stay inline. `pre` below is a passthrough so the box lives here,
85+
// where the raw code string is in hand.
86+
if (match || text.includes("\n")) {
5487
return (
55-
<HighlightedCode
56-
code={String(children).replace(/\n$/, "")}
57-
language={match[1]}
58-
className="rounded-sm bg-muted/50 text-xs"
59-
/>
88+
<ChatCodeBlock code={text}>
89+
{match ? (
90+
<HighlightedCode
91+
code={text}
92+
language={match[1]}
93+
className="text-xs"
94+
/>
95+
) : (
96+
<code className="font-mono text-xs">{text}</code>
97+
)}
98+
</ChatCodeBlock>
6099
);
61100
}
62101
return (
@@ -65,11 +104,7 @@ const components: Components = {
65104
</code>
66105
);
67106
},
68-
pre: ({ children }) => (
69-
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/50 p-3 text-sm leading-[1.5]">
70-
{children}
71-
</pre>
72-
),
107+
pre: ({ children }) => <>{children}</>,
73108
h1: ({ children }) => (
74109
<Heading size="xl" className="font-bold">
75110
{children}
@@ -153,9 +188,9 @@ export const ChatStreamingMarkdown = memo(function ChatStreamingMarkdown({
153188
{openFence.before.trim() ? (
154189
<ChatMarkdown content={openFence.before} />
155190
) : null}
156-
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/50 p-3 text-sm leading-[1.5]">
191+
<ChatCodeBlock code={openFence.code}>
157192
<code className="font-mono text-xs">{openFence.code}</code>
158-
</pre>
193+
</ChatCodeBlock>
159194
</div>
160195
);
161196
}

packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { CaretDown, ChatCircle, FileText, Scroll } from "@phosphor-icons/react";
1+
import {
2+
CaretDown,
3+
ChatCircle,
4+
Check,
5+
Copy,
6+
FileText,
7+
Scroll,
8+
} from "@phosphor-icons/react";
29
import { WorkerPoolContextProvider } from "@pierre/diffs/react";
310
import { useService } from "@posthog/di/react";
411
import {
@@ -64,10 +71,12 @@ import {
6471
} from "@posthog/ui/features/sessions/useSessionTaskId";
6572
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
6673
import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage";
74+
import { useCopy } from "@posthog/ui/primitives/useCopy";
6775
import {
6876
DIFF_WORKER_FACTORY,
6977
type DiffWorkerFactory,
7078
} from "@posthog/ui/shell/diffWorkerHost";
79+
import { IconButton, Tooltip } from "@radix-ui/themes";
7180
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
7281
import {
7382
memo,
@@ -540,8 +549,10 @@ const AgentProse = memo(function AgentProse({
540549
isStreaming?: boolean;
541550
}) {
542551
const smoothed = useSmoothedText(text);
552+
const { copied, copy } = useCopy();
553+
543554
return (
544-
<ChatMessage align="start">
555+
<ChatMessage align="start" className="group/msg">
545556
<ChatMessageContent className="gap-1">
546557
<ChatBubble variant="ghost">
547558
<ChatBubbleContent>
@@ -552,6 +563,22 @@ const AgentProse = memo(function AgentProse({
552563
)}
553564
</ChatBubbleContent>
554565
</ChatBubble>
566+
{isStreaming ? null : (
567+
<ChatMessageFooter className="opacity-0 transition-opacity group-hover/msg:opacity-100">
568+
<Tooltip content={copied ? "Copied!" : "Copy message"}>
569+
<IconButton
570+
size="1"
571+
variant="ghost"
572+
color={copied ? "green" : "gray"}
573+
onClick={() => copy(text)}
574+
className="cursor-pointer"
575+
aria-label="Copy message"
576+
>
577+
{copied ? <Check size={14} /> : <Copy size={14} />}
578+
</IconButton>
579+
</Tooltip>
580+
</ChatMessageFooter>
581+
)}
555582
</ChatMessageContent>
556583
</ChatMessage>
557584
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useCallback, useState } from "react";
2+
3+
/**
4+
* Copy-to-clipboard with a transient `copied` flag for button feedback. Clipboard writes can reject
5+
* (blocked permission, unfocused document, insecure context) — the rejection is swallowed and
6+
* `copied` stays false, so the button never reports a success that didn't happen.
7+
*/
8+
export function useCopy(resetMs = 2000): {
9+
copied: boolean;
10+
copy: (text: string) => void;
11+
} {
12+
const [copied, setCopied] = useState(false);
13+
const copy = useCallback(
14+
(text: string) => {
15+
navigator.clipboard.writeText(text).then(
16+
() => {
17+
setCopied(true);
18+
setTimeout(() => setCopied(false), resetMs);
19+
},
20+
() => {},
21+
);
22+
},
23+
[resetMs],
24+
);
25+
return { copied, copy };
26+
}

0 commit comments

Comments
 (0)