Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"jose": "catalog:",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"mermaid": "^11.16.1",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
openUrlInPreview,
BrowserPreviewUnavailableError,
} from "../browser/openFileInPreview";
import { MermaidDiagram } from "./MermaidDiagram";

interface ChatMarkdownProps {
text: string;
Expand Down Expand Up @@ -1656,7 +1657,7 @@ function ChatMarkdown({

const language = extractFenceLanguage(codeBlock.className);
const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
return (
const codeFallback = (
<MarkdownCodeBlock
code={codeBlock.code}
language={language}
Expand All @@ -1675,6 +1676,17 @@ function ChatMarkdown({
</RenderErrorBoundary>
</MarkdownCodeBlock>
);
if (!isStreaming && language.toLowerCase() === "mermaid") {
return (
<MermaidDiagram
code={codeBlock.code}
theme={resolvedTheme}
fenceTitle={fenceTitle}
fallback={codeFallback}
/>
);
}
return codeFallback;
},
};
}, [
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/components/MermaidDiagram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mermaid = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}));

vi.mock("mermaid", () => ({ default: mermaid }));

import { renderMermaidDiagram } from "./MermaidDiagram";
import { serializeMarkdownCodeFence } from "../markdown-clipboard";

describe("renderMermaidDiagram", () => {
beforeEach(() => {
mermaid.initialize.mockReset();
mermaid.render.mockReset();
});

it("renders with strict security and the selected theme", async () => {
mermaid.render.mockResolvedValue({ svg: "<svg />" });

await renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "dark");

expect(mermaid.initialize).toHaveBeenCalledWith({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "dark",
});
expect(mermaid.render).toHaveBeenCalledWith("diagram-1", "flowchart LR\nA-->B");
});

it("continues rendering after an invalid diagram", async () => {
mermaid.render
.mockRejectedValueOnce(new Error("Invalid diagram"))
.mockResolvedValueOnce({ svg: "<svg />" });

await expect(renderMermaidDiagram("diagram-1", "invalid", "light")).rejects.toThrow();
await expect(
renderMermaidDiagram("diagram-2", "sequenceDiagram\nA->>B: Hi", "light"),
).resolves.toEqual({ svg: "<svg />" });
});

it("skips queued work after its diagram unmounts", async () => {
let finishFirstRender!: (result: { svg: string }) => void;
mermaid.render.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirstRender = resolve;
}),
);

const first = renderMermaidDiagram("diagram-1", "flowchart LR\nA-->B", "light");
await vi.waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1));
const second = renderMermaidDiagram("diagram-2", "flowchart LR\nB-->C", "light", () => false);
finishFirstRender({ svg: "<svg />" });

await first;
await expect(second).resolves.toBeNull();
expect(mermaid.render).toHaveBeenCalledTimes(1);
});

it("chooses a fence longer than backtick runs in copied source", () => {
expect(serializeMarkdownCodeFence("flowchart LR\n%% ``` in a comment", "mermaid")).toBe(
"````mermaid\nflowchart LR\n%% ``` in a comment\n````\n\n",
);
});
});
100 changes: 100 additions & 0 deletions apps/web/src/components/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { RenderResult } from "mermaid";

import { serializeMarkdownCodeFence } from "../markdown-clipboard";

type MermaidTheme = "light" | "dark";

// Mermaid configuration is global, so initialization and rendering must stay paired.
let mermaidRenderQueue = Promise.resolve();

export function renderMermaidDiagram(
id: string,
code: string,
theme: MermaidTheme,
isActive: () => boolean = () => true,
) {
const render = async () => {
if (!isActive()) return null;
const { default: mermaid } = await import("mermaid");
if (!isActive()) return null;
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: theme === "dark" ? "dark" : "default",
});
return mermaid.render(id, code);
};

const result = mermaidRenderQueue.then(render, render);
mermaidRenderQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}

export function MermaidDiagram({
code,
theme,
fenceTitle,
fallback,
}: {
code: string;
theme: MermaidTheme;
fenceTitle: string | null;
fallback: ReactNode;
}) {
const reactId = useId();
const diagramId = `t3-mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const renderSequenceRef = useRef(0);
const diagramRef = useRef<HTMLDivElement>(null);
const inputKey = `${theme}\0${code}`;
const [renderState, setRenderState] = useState<{
inputKey: string;
result: RenderResult;
} | null>(null);
const result = renderState?.inputKey === inputKey ? renderState.result : null;

useEffect(() => {
let active = true;
const renderId = `${diagramId}-${renderSequenceRef.current++}`;
void renderMermaidDiagram(renderId, code, theme, () => active).then(
(nextResult) => {
if (active && nextResult) setRenderState({ inputKey, result: nextResult });
},
() => undefined,
);
return () => {
active = false;
};
}, [code, diagramId, inputKey, theme]);
Comment thread
cursor[bot] marked this conversation as resolved.

useLayoutEffect(() => {
const svg = diagramRef.current?.querySelector("svg");
const width = svg?.viewBox.baseVal.width ?? 0;
if (svg && Number.isFinite(width) && width > 0) {
svg.style.width = `${Math.ceil(width)}px`;
svg.style.maxWidth = "none";
}
}, [result]);

if (!result) return fallback;

return (
<figure
className="chat-markdown-mermaid"
data-markdown-copy={serializeMarkdownCodeFence(code, "mermaid")}
>
{fenceTitle ? (
<figcaption className="chat-markdown-mermaid-title">{fenceTitle}</figcaption>
) : null}
<div
ref={diagramRef}
className="chat-markdown-mermaid-canvas"
dangerouslySetInnerHTML={{ __html: result.svg }}
/>
</figure>
);
}
29 changes: 29 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2083,6 +2083,35 @@ label:has(> select#reasoning-effort) select {
background: transparent !important;
}

.chat-markdown .chat-markdown-mermaid {
max-width: 100%;
margin: 0.65rem 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--muted);
}

.chat-markdown .chat-markdown-mermaid-title {
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.75rem;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
font-size: 0.6875rem;
}

.chat-markdown .chat-markdown-mermaid-canvas {
overflow-x: auto;
padding: 1rem;
}

.chat-markdown .chat-markdown-mermaid-canvas svg {
display: block;
max-width: none;
height: auto;
margin: 0 auto;
}

/* Diagnostics-style tables: row separators only, uppercase headers, and a
scroll-fade container for horizontal overflow. The root chat-markdown
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ function codeFenceFor(code: string): string {
return "`".repeat(Math.max(3, longestRun + 1));
}

export function serializeMarkdownCodeFence(code: string, infoString: string): string {
const normalizedCode = code.replace(/\n$/, "");
const fence = codeFenceFor(normalizedCode);
return `${fence}${infoString}\n${normalizedCode}\n${fence}\n\n`;
}

function resolveCodeBlockLanguage(pre: Element): string | null {
const declared =
pre.closest("[data-language]")?.getAttribute("data-language") ??
Expand All @@ -64,9 +70,7 @@ function resolveCodeBlockLanguage(pre: Element): string | null {
}

function serializeCodeBlock(pre: Element): string {
const code = (pre.textContent ?? "").replace(/\n$/, "");
const fence = codeFenceFor(code);
return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`;
return serializeMarkdownCodeFence(pre.textContent ?? "", resolveCodeBlockLanguage(pre) ?? "");
}

function serializeTableCell(cell: Element): string {
Expand Down
Loading
Loading