diff --git a/ui/src/components/ChatMarkdownMessage.test.tsx b/ui/src/components/ChatMarkdownMessage.test.tsx new file mode 100644 index 00000000..6661e874 --- /dev/null +++ b/ui/src/components/ChatMarkdownMessage.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ThemeProvider } from "../context/ThemeContext"; +import { ChatMarkdownMessage } from "./ChatMarkdownMessage"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// Mock clipboard API (not available in jsdom by default) +const writeText = vi.fn().mockResolvedValue(undefined); +Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, +}); + +describe("ChatMarkdownMessage", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + writeText.mockClear(); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders markdown headings", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + const heading = container.querySelector("h1"); + expect(heading).not.toBeNull(); + expect(heading?.textContent).toBe("Hello World"); + }); + + it("renders markdown bold and italic", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + expect(container.querySelector("strong")).not.toBeNull(); + expect(container.querySelector("em")).not.toBeNull(); + }); + + it("renders markdown lists", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + expect(container.querySelector("ul")).not.toBeNull(); + const items = container.querySelectorAll("li"); + expect(items.length).toBe(2); + }); + + it("renders markdown links", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toBe("https://example.com"); + }); + + it("renders markdown tables (GFM)", () => { + const tableContent = "| A | B |\n|---|---|\n| 1 | 2 |"; + act(() => { + createRoot(container).render( + + + , + ); + }); + expect(container.querySelector("table")).not.toBeNull(); + expect(container.querySelector("th")).not.toBeNull(); + }); + + it("renders code block with copy button", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + const copyBtn = container.querySelector('[aria-label="Copy code"]'); + expect(copyBtn).not.toBeNull(); + }); + + it("renders code block with language label when language is specified", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + expect(container.textContent).toContain("typescript"); + }); + + it("clicking copy button calls clipboard.writeText with code content", async () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + const copyBtn = container.querySelector('[aria-label="Copy code"]') as HTMLButtonElement | null; + expect(copyBtn).not.toBeNull(); + await act(async () => { + copyBtn?.click(); + }); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("const y = 2;")); + }); + + it("inline code renders without copy button", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + const copyBtn = container.querySelector('[aria-label="Copy code"]'); + expect(copyBtn).toBeNull(); + expect(container.querySelector("code")).not.toBeNull(); + }); + + it("renders inline images with img tag", () => { + act(() => { + createRoot(container).render( + + + , + ); + }); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe("https://example.com/img.png"); + expect(img?.getAttribute("alt")).toBe("alt text"); + }); +}); diff --git a/ui/src/components/ChatMarkdownMessage.tsx b/ui/src/components/ChatMarkdownMessage.tsx new file mode 100644 index 00000000..42c1b134 --- /dev/null +++ b/ui/src/components/ChatMarkdownMessage.tsx @@ -0,0 +1,99 @@ +import { useCallback, useState, type ReactNode } from "react"; +import Markdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; +import { Copy, Check } from "lucide-react"; +import { cn } from "../lib/utils"; +import { Button } from "@/components/ui/button"; + +interface ChatMarkdownMessageProps { + content: string; + className?: string; +} + +function extractText(node: ReactNode): string { + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(extractText).join(""); + if (node && typeof node === "object" && "props" in node) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return extractText((node as any).props.children); + } + return ""; +} + +function CodeBlock({ + children, + className, + ...props +}: { + children?: ReactNode; + className?: string; + [key: string]: unknown; +}) { + const [copied, setCopied] = useState(false); + const language = className?.replace(/^language-/, "") ?? null; + const codeText = extractText(children); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(codeText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [codeText]); + + return ( +
+ {language && ( + + {language} + + )} + +
+        {children}
+      
+
+ ); +} + +const components: Partial = { + pre({ children, ...props }) { + // Check if child is a element with language class (code block) + if (children && typeof children === "object" && "props" in (children as object)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const childProps = (children as any).props; + const childClassName: string = childProps?.className ?? ""; + return ( + + {childProps?.children} + + ); + } + return
{children}
; + }, +}; + +export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) { + return ( +
+ + {content} + +
+ ); +}