diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c1eb59ac..b996629a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; @@ -10,6 +10,30 @@ interface MarkdownBodyProps { className?: string; } +let mermaidLoaderPromise: Promise | null = null; + +function loadMermaid() { + if (!mermaidLoaderPromise) { + mermaidLoaderPromise = import("mermaid").then((module) => module.default); + } + return mermaidLoaderPromise; +} + +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(""); + return ""; +} + +function extractMermaidSource(children: ReactNode): string | null { + if (!isValidElement(children)) return null; + const childProps = children.props as { className?: unknown; children?: ReactNode }; + if (typeof childProps.className !== "string") return null; + if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null; + return flattenText(childProps.children).replace(/\n$/, ""); +} + function hexToRgb(hex: string): { r: number; g: number; b: number } | null { const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); if (!match) return null; @@ -33,6 +57,61 @@ function mentionChipStyle(color: string | null): CSSProperties | undefined { }; } +function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { + const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const [svg, setSvg] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + setSvg(null); + setError(null); + + loadMermaid() + .then(async (mermaid) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: darkMode ? "dark" : "default", + fontFamily: "inherit", + suppressErrorRendering: true, + }); + const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source); + if (!active) return; + setSvg(rendered.svg); + }) + .catch((err) => { + if (!active) return; + const message = + err instanceof Error && err.message + ? err.message + : "Failed to render Mermaid diagram."; + setError(message); + }); + + return () => { + active = false; + }; + }, [darkMode, renderId, source]); + + return ( +
+ {svg ? ( +
+ ) : ( + <> +

+ {error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."} +

+
+            {source}
+          
+ + )} +
+ ); +} + export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); return ( @@ -46,6 +125,13 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) { { + const mermaidSource = extractMermaidSource(preChildren); + if (mermaidSource) { + return ; + } + return
{preChildren}
; + }, a: ({ href, children: linkChildren }) => { const parsed = href ? parseProjectMentionHref(href) : null; if (parsed) { diff --git a/ui/src/index.css b/ui/src/index.css index 0fa33136..63a8c3dc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -426,6 +426,40 @@ font-weight: 500; } +.paperclip-mermaid { + margin: 0.5rem 0; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 3px); + background-color: color-mix(in oklab, var(--accent) 35%, transparent); + overflow-x: auto; +} + +.paperclip-mermaid svg { + display: block; + width: max-content; + max-width: none; + min-width: 100%; + height: auto; +} + +.paperclip-mermaid-status { + margin: 0 0 0.45rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.paperclip-mermaid-status-error { + color: var(--destructive); +} + +.paperclip-mermaid-source { + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + /* Project mention chips rendered inside MarkdownBody */ a.paperclip-project-mention-chip { display: inline-flex;