Skip to content
Closed
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 @@ -48,6 +48,7 @@
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
"lucide-react": "^0.564.0",
"mermaid": "^11.17.2",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
Expand Down
57 changes: 35 additions & 22 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import { GitHubIcon } from "./Icons";
import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting";
import { HighlightedCodeLines } from "./chat/HighlightedCodeLines";
import { RenderErrorBoundary } from "./RenderErrorBoundary";
import { MermaidDiagram } from "./chat/MermaidDiagram";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings, useClientSettings } from "../hooks/useSettings";
import {
Expand Down Expand Up @@ -227,6 +228,8 @@ interface ChatMarkdownProps {
/** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's
uploads need; without it those images and videos load unauthenticated and 404. */
githubMedia?: boolean | undefined;
/** Draws `mermaid` fences as diagrams, for GitHub-authored text written to be rendered that way. */
mermaidDiagrams?: boolean | undefined;
/** Levels added to each markdown heading in the accessibility tree so the
text nests under the heading that introduces it, such as a chat message's
author. Rendered tags and their styling are unchanged. */
Expand Down Expand Up @@ -2286,6 +2289,7 @@ function areMarkdownFileLinkPropsEqual(
);
}

/** State for one rendered message: the context its node renderers read, plus copy and media handlers. */
function useChatMarkdownState({
text,
cwd,
Expand All @@ -2302,6 +2306,7 @@ function useChatMarkdownState({
renderContextReference,
headingLevelOffset = 0,
githubMedia = false,
mermaidDiagrams = false,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
Expand Down Expand Up @@ -2701,6 +2706,7 @@ function useChatMarkdownState({
isStreaming,
linkTargetPreference,
markdownFileLinkMetaByHref,
mermaidDiagrams,
onTaskListChange,
onUseArtifactTemplate,
onRunShellCommand,
Expand Down Expand Up @@ -2732,6 +2738,7 @@ function useChatMarkdownState({
isStreaming,
linkTargetPreference,
markdownFileLinkMetaByHref,
mermaidDiagrams,
onTaskListChange,
onUseArtifactTemplate,
onRunShellCommand,
Expand Down Expand Up @@ -3265,10 +3272,10 @@ const CHAT_MARKDOWN_COMPONENTS = {
details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) {
return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
},
/** Fenced code: a highlighted block, or a mermaid diagram when the renderer opts in. */
pre: function MarkdownPre({ node, children, ...props }) {
const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use(
ChatMarkdownRendererContext,
);
const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text, mermaidDiagrams } =
use(ChatMarkdownRendererContext);
const codeBlock = extractCodeBlock(children);
if (!codeBlock) {
return <pre {...props}>{children}</pre>;
Expand All @@ -3289,27 +3296,33 @@ const CHAT_MARKDOWN_COMPONENTS = {
}
isStreaming={isStreaming}
>
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
fallback={<pre {...props}>{children}</pre>}
{/* A half-written diagram cannot parse, so streaming text keeps showing its source. */}
<MermaidDiagram
code={mermaidDiagrams && !isStreaming && language === "mermaid" ? codeBlock.code : null}
theme={resolvedTheme}
>
{/* Reserve the block's height but stay hidden until Shiki has colored
it, so plain text never flashes before the highlighted version. */}
<Suspense
fallback={
<pre {...props} className="invisible" aria-hidden>
{children}
</pre>
}
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
fallback={<pre {...props}>{children}</pre>}
>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
{/* Reserve the block's height but stay hidden until Shiki has colored
it, so plain text never flashes before the highlighted version. */}
<Suspense
fallback={
<pre {...props} className="invisible" aria-hidden>
{children}
</pre>
}
>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
</MermaidDiagram>
</MarkdownCodeBlock>
);
},
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/components/chat/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect, useState, type ReactNode } from "react";

import { getCachedMermaidSvg, renderMermaidSvg, type MermaidTheme } from "~/lib/mermaid";

/**
* Draws `code` as a mermaid diagram, the way GitHub does. Shows `children`, the highlighted
* source, when `code` is null, while the diagram renders, or when mermaid cannot parse it.
*/
export function MermaidDiagram({
code,
theme,
children,
}: {
code: string | null;
theme: MermaidTheme;
children: ReactNode;
}) {
const key = `${theme}\n${code}`;
const [resolved, setResolved] = useState<{ key: string; svg: string | null } | null>(null);
// A cache hit draws on the first frame, so remounting a scrolled body does not flash.
const cached = code === null ? null : getCachedMermaidSvg(code, theme);
const svg = cached ?? (resolved?.key === key ? resolved.svg : null);

useEffect(() => {
if (code === null || cached != null) return;
let cancelled = false;
void renderMermaidSvg(code, theme).then(
(rendered) => !cancelled && setResolved({ key, svg: rendered }),
() => !cancelled && setResolved({ key, svg: null }),
);
return () => {
cancelled = true;
};
}, [cached, code, key, theme]);

if (svg === null) return children;
return (
<div
className="chat-markdown-mermaid flex justify-center overflow-x-auto px-3 pt-1 pb-3"
// Mermaid runs its own DOMPurify pass at the `strict` security level set in lib/mermaid.
dangerouslySetInnerHTML={{ __html: svg }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function PullRequestMarkdown({
environmentId={environmentId}
extraRemarkPlugins={extraRemarkPlugins}
githubMedia
mermaidDiagrams
/>
);
}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,13 @@ code {
}
}

/* Mermaid sizes its SVG from the diagram's own layout, which overflows a narrow right panel.
Let it shrink to the panel and scroll only when its own minimum width wins. */
.chat-markdown .chat-markdown-mermaid svg {
max-width: 100%;
height: 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
67 changes: 67 additions & 0 deletions apps/web/src/lib/mermaid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

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

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

const { getCachedMermaidSvg, renderMermaidSvg } = await import("./mermaid");

beforeEach(() => {
mocks.initialize.mockClear();
mocks.render.mockClear();
mocks.render.mockImplementation((_id: string, code: string) =>
Promise.resolve({ svg: `<svg data-code="${code}" />` }),
);
});

describe("renderMermaidSvg", () => {
it("renders once and serves repeats from the cache", async () => {
const code = "graph TD; cached-->once;";
expect(getCachedMermaidSvg(code, "dark")).toBeNull();

const first = await renderMermaidSvg(code, "dark");
const second = await renderMermaidSvg(code, "dark");

expect(first).toBe(second);
expect(getCachedMermaidSvg(code, "dark")).toBe(first);
expect(mocks.render).toHaveBeenCalledTimes(1);
});

it("collapses concurrent renders of the same diagram", async () => {
const code = "graph TD; concurrent-->once;";

const [first, second] = await Promise.all([
renderMermaidSvg(code, "dark"),
renderMermaidSvg(code, "dark"),
]);

expect(first).toBe(second);
expect(mocks.render).toHaveBeenCalledTimes(1);
});

it("caches per theme and reconfigures mermaid when the theme changes", async () => {
const code = "graph TD; themed-->twice;";

const dark = await renderMermaidSvg(code, "dark");
const light = await renderMermaidSvg(code, "light");

expect(getCachedMermaidSvg(code, "dark")).toBe(dark);
expect(getCachedMermaidSvg(code, "light")).toBe(light);
expect(mocks.render).toHaveBeenCalledTimes(2);
expect(mocks.initialize.mock.calls.at(-1)?.[0]).toMatchObject({ theme: "default" });
});

it("keeps rendering after a diagram fails to parse", async () => {
mocks.render.mockRejectedValueOnce(new Error("Parse error on line 2"));

await expect(renderMermaidSvg("graph TD; broken", "light")).rejects.toThrow(
"Parse error on line 2",
);
await expect(renderMermaidSvg("graph TD; ok-->after;", "light")).resolves.toContain("<svg");
});
});
51 changes: 51 additions & 0 deletions apps/web/src/lib/mermaid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { LRUCache } from "./lruCache";

export type MermaidTheme = "light" | "dark";

// Scrolling a long body remounts every diagram in it, and mermaid's layout pass is the
// expensive part. Keyed by the source itself: a hash collision would draw the wrong diagram.
const renderedSvgCache = new LRUCache<string>(64, 8 * 1024 * 1024);

/** The SVG from an earlier render of this diagram in this theme, so remounts draw at once. */
export function getCachedMermaidSvg(code: string, theme: MermaidTheme): string | null {
return renderedSvgCache.get(`${theme}\n${code}`);
}

// Mermaid's config and render target are module-global, so renders join one chain instead
// of trampling each other's theme.
let renderQueue: Promise<unknown> = Promise.resolve();
let configuredTheme: MermaidTheme | null = null;
let renderCount = 0;

/** Renders on first use only: mermaid is far too large for the main bundle. */
export function renderMermaidSvg(code: string, theme: MermaidTheme): Promise<string> {
const cached = getCachedMermaidSvg(code, theme);
if (cached != null) return Promise.resolve(cached);

const render = renderQueue.then(async () => {
// Copies of one diagram queue together; the first one through fills the cache.
const alreadyRendered = getCachedMermaidSvg(code, theme);
if (alreadyRendered != null) return alreadyRendered;

const { default: mermaid } = await import("mermaid");
if (configuredTheme !== theme) {
mermaid.initialize({
startOnLoad: false,
// Pull request bodies are attacker-controlled: keep DOMPurify on and click/script off.
securityLevel: "strict",
// Otherwise mermaid injects its own error graph next to the React fallback.
suppressErrorRendering: true,
theme: theme === "dark" ? "dark" : "default",
});
configuredTheme = theme;
}
renderCount += 1;
const { svg } = await mermaid.render(`markdown-mermaid-${renderCount}`, code);
renderedSvgCache.set(`${theme}\n${code}`, svg, svg.length * 2);
return svg;
});

// A failed diagram must not poison the queue for the next one.
renderQueue = render.catch(() => undefined);
return render;
}
Loading
Loading