1+ import { Check , Copy } from "@phosphor-icons/react" ;
12import {
23 Heading ,
34 Separator ,
@@ -14,11 +15,41 @@ import {
1415 splitMarkdownBlocks ,
1516} from "@posthog/ui/features/editor/components/splitMarkdownBlocks" ;
1617import { 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" ;
1821import Markdown , { type Components } from "react-markdown" ;
1922import rehypeSanitize from "rehype-sanitize" ;
2023import 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 = / l a n g u a g e - ( \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 }
0 commit comments