From 1a15ecec389f4fc8a1f7225f5b2458553e8223ce Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 7 Jul 2026 12:22:11 -0400 Subject: [PATCH 1/2] feat(chat): add copy buttons to code blocks and agent prose messages Generated-By: PostHog Code --- .../components/chat-thread/ChatMarkdown.tsx | 72 ++++++++++++++----- .../components/chat-thread/ChatThread.tsx | 36 +++++++++- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx index 824801ac4d..729d761f15 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { Check, Copy } from "@phosphor-icons/react"; import { Heading, Separator, @@ -14,11 +15,46 @@ import { splitMarkdownBlocks, } from "@posthog/ui/features/editor/components/splitMarkdownBlocks"; import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; -import { memo, useMemo } from "react"; +import { IconButton } from "@radix-ui/themes"; +import { memo, type ReactNode, useCallback, useMemo, useState } from "react"; import Markdown, { type Components } from "react-markdown"; import rehypeSanitize from "rehype-sanitize"; import remarkGfm from "remark-gfm"; +function ChatCodeBlock({ + code, + children, +}: { + code: string; + children: ReactNode; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [code]); + + return ( +
+
+        {children}
+      
+ + {copied ? : } + +
+ ); +} + /** * The chat thread's own markdown renderer — intentionally separate from the app-wide * `MarkdownRenderer` (which carries PostHog deeplink handling, Radix Text wrappers, and other @@ -47,16 +83,24 @@ const components: Components = { ), li: ({ children }) =>
  • {children}
  • , code: ({ className, children }) => { + const text = String(children).replace(/\n$/, ""); const match = /language-(\w+)/.exec(className ?? ""); - if (match) { - // Fenced block with a language → Shiki-highlighted (theme-aware). The `pre` renderer - // below provides the box; HighlightedCode renders the colored inside it. + // Fenced blocks (carry a language, or span multiple lines) render as a boxed, copyable + // block; short inline spans stay inline. `pre` below is a passthrough so the box lives here, + // where the raw code string is in hand. + if (match || text.includes("\n")) { return ( - + + {match ? ( + + ) : ( + {text} + )} + ); } return ( @@ -65,11 +109,7 @@ const components: Components = { ); }, - pre: ({ children }) => ( -
    -      {children}
    -    
    - ), + pre: ({ children }) => <>{children}, h1: ({ children }) => ( {children} @@ -153,9 +193,9 @@ export const ChatStreamingMarkdown = memo(function ChatStreamingMarkdown({ {openFence.before.trim() ? ( ) : null} -
    +              
                     {openFence.code}
    -              
    + ); } diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 5a74c918d6..82593eeecc 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -1,4 +1,11 @@ -import { CaretDown, ChatCircle, FileText, Scroll } from "@phosphor-icons/react"; +import { + CaretDown, + ChatCircle, + Check, + Copy, + FileText, + Scroll, +} from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; import { @@ -68,6 +75,7 @@ import { DIFF_WORKER_FACTORY, type DiffWorkerFactory, } from "@posthog/ui/shell/diffWorkerHost"; +import { IconButton, Tooltip } from "@radix-ui/themes"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { memo, @@ -540,8 +548,16 @@ const AgentProse = memo(function AgentProse({ isStreaming?: boolean; }) { const smoothed = useSmoothedText(text); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + return ( - + @@ -552,6 +568,22 @@ const AgentProse = memo(function AgentProse({ )} + {isStreaming ? null : ( + + + + {copied ? : } + + + + )} ); From b0bcbdad2a00b8b8b0d9e0f4bce6d071c06254cd Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 7 Jul 2026 12:28:01 -0400 Subject: [PATCH 2/2] refactor(chat): extract useCopy hook, handle clipboard rejection Swallows clipboard write failures so the button doesn't report a false "Copied!", and dedupes the copy state machine across ChatCodeBlock and AgentProse. Generated-By: PostHog Code Task-Id: b447d951-99cf-4194-9d7a-867d6ea04f32 --- .../components/chat-thread/ChatMarkdown.tsx | 13 +++------- .../components/chat-thread/ChatThread.tsx | 11 +++----- packages/ui/src/primitives/useCopy.ts | 26 +++++++++++++++++++ 3 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/primitives/useCopy.ts diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx index 729d761f15..c871c25187 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx @@ -15,8 +15,9 @@ import { splitMarkdownBlocks, } from "@posthog/ui/features/editor/components/splitMarkdownBlocks"; import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; +import { useCopy } from "@posthog/ui/primitives/useCopy"; import { IconButton } from "@radix-ui/themes"; -import { memo, type ReactNode, useCallback, useMemo, useState } from "react"; +import { memo, type ReactNode, useMemo } from "react"; import Markdown, { type Components } from "react-markdown"; import rehypeSanitize from "rehype-sanitize"; import remarkGfm from "remark-gfm"; @@ -28,13 +29,7 @@ function ChatCodeBlock({ code: string; children: ReactNode; }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [code]); + const { copied, copy } = useCopy(); return (
    @@ -45,7 +40,7 @@ function ChatCodeBlock({ size="1" variant="ghost" color={copied ? "green" : "gray"} - onClick={handleCopy} + onClick={() => copy(code)} className="absolute top-1 right-1 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100" aria-label="Copy code" > diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 82593eeecc..663878794d 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -71,6 +71,7 @@ import { } from "@posthog/ui/features/sessions/useSessionTaskId"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; +import { useCopy } from "@posthog/ui/primitives/useCopy"; import { DIFF_WORKER_FACTORY, type DiffWorkerFactory, @@ -548,13 +549,7 @@ const AgentProse = memo(function AgentProse({ isStreaming?: boolean; }) { const smoothed = useSmoothedText(text); - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [text]); + const { copied, copy } = useCopy(); return ( @@ -575,7 +570,7 @@ const AgentProse = memo(function AgentProse({ size="1" variant="ghost" color={copied ? "green" : "gray"} - onClick={handleCopy} + onClick={() => copy(text)} className="cursor-pointer" aria-label="Copy message" > diff --git a/packages/ui/src/primitives/useCopy.ts b/packages/ui/src/primitives/useCopy.ts new file mode 100644 index 0000000000..32aef9dafc --- /dev/null +++ b/packages/ui/src/primitives/useCopy.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from "react"; + +/** + * Copy-to-clipboard with a transient `copied` flag for button feedback. Clipboard writes can reject + * (blocked permission, unfocused document, insecure context) — the rejection is swallowed and + * `copied` stays false, so the button never reports a success that didn't happen. + */ +export function useCopy(resetMs = 2000): { + copied: boolean; + copy: (text: string) => void; +} { + const [copied, setCopied] = useState(false); + const copy = useCallback( + (text: string) => { + navigator.clipboard.writeText(text).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), resetMs); + }, + () => {}, + ); + }, + [resetMs], + ); + return { copied, copy }; +}