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}
+
+
+ );
+}