diff --git a/ui/src/components/ChatCodeBlock.tsx b/ui/src/components/ChatCodeBlock.tsx new file mode 100644 index 00000000..c781625d --- /dev/null +++ b/ui/src/components/ChatCodeBlock.tsx @@ -0,0 +1,121 @@ +import { useState, type ReactNode, type HTMLAttributes } from "react"; +import { Copy, Check } from "lucide-react"; +import { Button } from "./ui/button"; +import type { ExtraProps } from "react-markdown"; + +type ChatCodeBlockProps = HTMLAttributes & ExtraProps; + +/** + * Recursively flatten React children into a plain text string. + */ +function flattenText(value: ReactNode): string { + if (value == null) return ""; + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map((item) => flattenText(item)).join(""); + if (typeof value === "object" && "props" in (value as object)) { + const el = value as { props?: { children?: ReactNode } }; + return flattenText(el.props?.children); + } + return ""; +} + +/** + * Extract the language name from a className like "language-typescript" -> "typescript". + */ +function extractLanguage(className?: string | null): string | null { + if (!className) return null; + const match = /\blanguage-(\w+)\b/.exec(className); + return match ? match[1] : null; +} + +/** + * Get the className of the first child code element, if any. + */ +function getCodeClassName(children: ReactNode): string | null { + if (children == null) return null; + if (typeof children === "object" && "props" in (children as object)) { + const el = children as { type?: unknown; props?: { className?: string } }; + if (el.type === "code" || String(el.type) === "code") { + return el.props?.className ?? null; + } + } + if (Array.isArray(children)) { + for (const child of children) { + const result = getCodeClassName(child); + if (result !== null) return result; + } + } + return null; +} + +/** + * Check whether children contain a code element (i.e., this is a fenced code block). + */ +function hasCodeChild(children: ReactNode): boolean { + if (children == null) return false; + if (typeof children === "object" && "props" in (children as object)) { + const el = children as { type?: unknown }; + if (el.type === "code" || String(el.type) === "code") { + return true; + } + } + if (Array.isArray(children)) { + return children.some((child) => hasCodeChild(child)); + } + return false; +} + +function doCopy(text: string) { + navigator.clipboard.writeText(text).catch(() => { + // Ignore clipboard errors in restricted environments + }); +} + +export function ChatCodeBlock({ children, className, ...props }: ChatCodeBlockProps) { + const [copied, setCopied] = useState(false); + + // If there's no code child, render a plain
 without the toolbar
+  if (!hasCodeChild(children)) {
+    return (
+      
+        {children}
+      
+ ); + } + + const codeClassName = getCodeClassName(children); + const language = extractLanguage(codeClassName); + const codeText = flattenText(children); + + function handleCopy() { + doCopy(codeText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( +
+      
+ {language ? ( + {language} + ) : ( + + )} + +
+ {children} +
+ ); +} diff --git a/ui/src/components/ChatMarkdownMessage.tsx b/ui/src/components/ChatMarkdownMessage.tsx new file mode 100644 index 00000000..4091524e --- /dev/null +++ b/ui/src/components/ChatMarkdownMessage.tsx @@ -0,0 +1,26 @@ +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; +import { ChatCodeBlock } from "./ChatCodeBlock"; +import { cn } from "../lib/utils"; + +interface ChatMarkdownMessageProps { + content: string; + className?: string; +} + +export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) { + return ( +
+ + {content} + +
+ ); +}