"use client"; import { useMemo, type MouseEvent } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import { resolveLocalFileHref } from "@/lib/file-links"; import { encodeFilePathForApi } from "@/lib/file-paths"; import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown"; import { MermaidBlock, CodeBlock } from "./MermaidBlock"; interface MarkdownBodyProps { children: string; className?: string; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; } export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); // Stable renderer identities keep stateful blocks mounted across message hover updates. const components = useMemo(() => ({ code({ className, children, ...props }) { const lang = className?.replace("language-", "").toLowerCase() ?? ""; const raw = String(children); const isBlock = className?.includes("language-") || raw.includes("\n"); if (isBlock) { if (lang === "mermaid") { return ; } return ; } return ( {children} ); }, pre({ children }) { return <>{children}; }, a({ href, children, ...props }) { // `node` is react-markdown metadata, not a DOM attribute. delete props.node; const filePath = onOpenFile ? resolveLocalFileHref(href, cwd) : null; const openFile = onOpenFile; if (!filePath || !openFile) { return ( {children} ); } const handleClick = (event: MouseEvent) => { if (event.defaultPrevented || event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const target = event.currentTarget.getAttribute("target"); if (target && target !== "_self") return; event.preventDefault(); openFile(filePath); }; return ( {children} ); }, img({ src, alt, ...props }) { delete props.node; const filePath = typeof src === "string" ? resolveLocalFileHref(src, cwd) : null; const imageSrc = filePath ? `/api/files/${encodeFilePathForApi(filePath)}?type=read` : src; // Dynamic local paths are served directly by the file API. // eslint-disable-next-line @next/next/no-img-element return {alt; }, table({ children }) { return (
{children}
); }, }), [cwd, isStreaming, onOpenFile]); return (
{normalizedMarkdown}
); }