Skip to content

Commit c17ef01

Browse files
committed
feat(web): render diff and Mermaid blocks in chat
1 parent 75e4ceb commit c17ef01

10 files changed

Lines changed: 1189 additions & 2 deletions

‎apps/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"jszip": "3.10.1",
4141
"lexical": "^0.41.0",
4242
"lucide-react": "^0.564.0",
43+
"mermaid": "^11.17.2",
4344
"react": "19.2.6",
4445
"react-dom": "19.2.6",
4546
"react-markdown": "^10.1.0",

‎apps/web/src/components/ChatMarkdown.test.tsx‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { create, type ReactTestRenderer } from "react-test-renderer";
55
import { describe, expect, it, vi } from "vite-plus/test";
66

77
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
8+
import * as mermaidRendering from "../lib/mermaidRendering";
9+
import { MarkdownDiffBlock } from "./MarkdownDiffBlock";
10+
import { MarkdownMermaidBlock } from "./MarkdownMermaidBlock";
811
import { GitHubIcon } from "./Icons";
912
import { Button } from "./ui/button";
1013
import { setMarkdownTaskChecked } from "./files/filePreviewMode";
@@ -72,6 +75,138 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
7275
return button.props as ComponentProps<typeof Button>;
7376
}
7477

78+
describe("ChatMarkdown code previews", () => {
79+
it.each(["diff", "PATCH"])(
80+
"switches %s snippets between preview and source without changing copied code",
81+
async (language) => {
82+
const source = " unchanged\n-old\n+new\n";
83+
const writeText = vi.fn(async (_text: string) => {});
84+
vi.stubGlobal("navigator", { clipboard: { writeText } });
85+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
86+
let renderer: ReactTestRenderer | undefined;
87+
try {
88+
await act(async () => {
89+
renderer = create(
90+
<ChatMarkdown
91+
cwd="/tmp/project"
92+
text={`\`\`\`${language}\n${source}\`\`\``}
93+
parseRawHtml={false}
94+
/>,
95+
);
96+
});
97+
const preview = renderer!.root.findByType(MarkdownDiffBlock);
98+
expect(
99+
preview
100+
.findAllByType("span")
101+
.flatMap((line) => line.children)
102+
.join(""),
103+
).toBe(source);
104+
const showSource = codeButton(renderer!, "Show source");
105+
await act(async () => {
106+
showSource.onClick?.({} as Parameters<NonNullable<typeof showSource.onClick>>[0]);
107+
});
108+
expect(renderer!.root.findAllByType(MarkdownDiffBlock)).toHaveLength(0);
109+
const copy = codeButton(renderer!, "Copy code");
110+
await act(async () => {
111+
copy.onClick?.({} as Parameters<NonNullable<typeof copy.onClick>>[0]);
112+
});
113+
expect(writeText).toHaveBeenCalledWith(source);
114+
const showPreview = codeButton(renderer!, "Show preview");
115+
await act(async () => {
116+
showPreview.onClick?.({} as Parameters<NonNullable<typeof showPreview.onClick>>[0]);
117+
});
118+
expect(
119+
renderer!.root
120+
.findByType(MarkdownDiffBlock)
121+
.findAllByType("span")
122+
.flatMap((line) => line.children)
123+
.join(""),
124+
).toBe(source);
125+
} finally {
126+
await act(async () => renderer?.unmount());
127+
vi.unstubAllGlobals();
128+
vi.restoreAllMocks();
129+
}
130+
},
131+
);
132+
133+
it("renders Mermaid only after streaming ends and lets the user return to its source", async () => {
134+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
135+
const renderDiagram = vi
136+
.spyOn(mermaidRendering, "renderMermaidDiagram")
137+
.mockResolvedValue("<svg><text>Draft</text></svg>");
138+
const text = "```mermaid\nflowchart LR\nDraft-->Review\n```";
139+
let renderer: ReactTestRenderer | undefined;
140+
try {
141+
await act(async () => {
142+
renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} isStreaming />, {
143+
createNodeMock: (element) =>
144+
element.type === "div" &&
145+
(element.props as ComponentProps<"div">).className === "chat-markdown-mermaid"
146+
? {}
147+
: null,
148+
});
149+
});
150+
expect(renderDiagram).not.toHaveBeenCalled();
151+
expect(renderer!.root.findAllByType(MarkdownMermaidBlock)).toHaveLength(0);
152+
await act(async () => {
153+
renderer!.update(<ChatMarkdown cwd="/tmp/project" text={text} />);
154+
});
155+
expect(
156+
renderer!.root.findByProps({ role: "img" }).props.dangerouslySetInnerHTML.__html,
157+
).toContain("Draft");
158+
const showSource = codeButton(renderer!, "Show source");
159+
await act(async () => {
160+
showSource.onClick?.({} as Parameters<NonNullable<typeof showSource.onClick>>[0]);
161+
});
162+
expect(renderer!.root.findAllByProps({ role: "img" })).toHaveLength(0);
163+
const showPreview = codeButton(renderer!, "Show preview");
164+
await act(async () => {
165+
showPreview.onClick?.({} as Parameters<NonNullable<typeof showPreview.onClick>>[0]);
166+
});
167+
expect(
168+
renderer!.root.findByProps({ role: "img" }).props.dangerouslySetInnerHTML.__html,
169+
).toContain("Draft");
170+
} finally {
171+
await act(async () => renderer?.unmount());
172+
vi.unstubAllGlobals();
173+
vi.restoreAllMocks();
174+
}
175+
});
176+
177+
it("keeps malformed Mermaid source readable when diagram rendering fails", async () => {
178+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
179+
vi.spyOn(mermaidRendering, "renderMermaidDiagram").mockRejectedValue(
180+
new Error("Invalid diagram"),
181+
);
182+
let renderer: ReactTestRenderer | undefined;
183+
try {
184+
await act(async () => {
185+
renderer = create(
186+
<ChatMarkdown cwd="/tmp/project" text={"```mermaid\ninvalid diagram\n```"} />,
187+
{
188+
createNodeMock: (element) =>
189+
element.type === "div" &&
190+
(element.props as ComponentProps<"div">).className === "chat-markdown-mermaid"
191+
? {}
192+
: null,
193+
},
194+
);
195+
});
196+
expect(renderer!.root.findByProps({ role: "status" }).children.join("")).toBe(
197+
"Unable to render this diagram. Showing source.",
198+
);
199+
expect(renderer!.root.findByType("pre").findByType("code").children.join("")).toBe(
200+
"invalid diagram\n",
201+
);
202+
} finally {
203+
await act(async () => renderer?.unmount());
204+
vi.unstubAllGlobals();
205+
vi.restoreAllMocks();
206+
}
207+
});
208+
});
209+
75210
describe("ChatMarkdown favicon privacy", () => {
76211
it("suppresses private link images while preserving public links across updates", async () => {
77212
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);

‎apps/web/src/components/ChatMarkdown.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ import { LRUCache } from "../lib/lruCache";
122122
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
123123
import { GitHubIcon } from "./Icons";
124124
import { RenderErrorBoundary } from "./RenderErrorBoundary";
125+
import { MarkdownDiffBlock } from "./MarkdownDiffBlock";
126+
import { MarkdownMermaidBlock } from "./MarkdownMermaidBlock";
125127
import { useTheme } from "../hooks/useTheme";
126128
import { getClientSettings, useClientSettings } from "../hooks/useSettings";
127129
import {
@@ -521,6 +523,7 @@ const GITHUB_ALERT_PRESENTATIONS: Record<
521523
function extractFenceLanguage(className: string | undefined): string {
522524
const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
523525
const raw = match?.[1] ?? "text";
526+
if (raw.toLowerCase() === "patch") return "diff";
524527
// Shiki doesn't bundle a gitignore grammar; ini is a close match (#685)
525528
return raw === "gitignore" ? "ini" : raw;
526529
}
@@ -889,14 +892,17 @@ function MarkdownCodeBlock({
889892
fenceTitle,
890893
theme,
891894
children,
895+
preview,
892896
}: {
893897
code: string;
894898
language: string;
895899
fenceTitle: string | null;
896900
theme: "light" | "dark";
897901
children: ReactNode;
902+
preview?: ReactNode;
898903
}) {
899904
const [copied, setCopied] = useState(false);
905+
const [showSource, setShowSource] = useState(false);
900906
const [wrapped, setWrapped] = useState(readInitialWordWrapSetting);
901907
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
902908
const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
@@ -955,6 +961,18 @@ function MarkdownCodeBlock({
955961
/>
956962
</span>
957963
<span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
964+
{preview != null && (
965+
<Button
966+
type="button"
967+
variant="ghost"
968+
size="xs"
969+
className="chat-markdown-chrome-action"
970+
aria-label={showSource ? "Show preview" : "Show source"}
971+
onClick={() => setShowSource((value) => !value)}
972+
>
973+
{showSource ? "Preview" : "Source"}
974+
</Button>
975+
)}
958976
<Tooltip>
959977
<TooltipTrigger
960978
render={
@@ -992,7 +1010,7 @@ function MarkdownCodeBlock({
9921010
</Tooltip>
9931011
</span>
9941012
</div>
995-
{children}
1013+
{preview != null && !showSource ? preview : children}
9961014
</div>
9971015
);
9981016
}
@@ -3094,6 +3112,13 @@ const CHAT_MARKDOWN_COMPONENTS = {
30943112
language={language}
30953113
fenceTitle={fenceTitle}
30963114
theme={resolvedTheme}
3115+
preview={
3116+
language.toLowerCase() === "diff" || language.toLowerCase() === "patch" ? (
3117+
<MarkdownDiffBlock code={codeBlock.code} />
3118+
) : language.toLowerCase() === "mermaid" && !isStreaming ? (
3119+
<MarkdownMermaidBlock code={codeBlock.code} theme={resolvedTheme} />
3120+
) : undefined
3121+
}
30973122
>
30983123
<RenderErrorBoundary
30993124
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/** Displays both complete patches and headerless diff snippets without changing their source. */
2+
export function MarkdownDiffBlock({ code }: { code: string }) {
3+
const lines = code.split("\n");
4+
if (lines.at(-1) === "") lines.pop();
5+
6+
return (
7+
<pre className="chat-markdown-diff" aria-label="Diff">
8+
<code>
9+
{lines.map((line, index) => {
10+
const kind = /^(--- |\+\+\+ |diff |index )/.test(line)
11+
? "header"
12+
: line.startsWith("@@")
13+
? "hunk"
14+
: line.startsWith("+")
15+
? "added"
16+
: line.startsWith("-")
17+
? "removed"
18+
: "context";
19+
return (
20+
// Rows are stateless and identified by their position in the source.
21+
// eslint-disable-next-line react/no-array-index-key
22+
<span key={index} data-diff-line={kind}>
23+
{line}
24+
{index < lines.length - 1 || code.endsWith("\n") ? "\n" : null}
25+
</span>
26+
);
27+
})}
28+
</code>
29+
</pre>
30+
);
31+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
import { renderMermaidDiagram } from "../lib/mermaidRendering";
4+
5+
export function MarkdownMermaidBlock({ code, theme }: { code: string; theme: "light" | "dark" }) {
6+
const containerRef = useRef<HTMLDivElement>(null);
7+
const [visible, setVisible] = useState(() => typeof IntersectionObserver === "undefined");
8+
const [result, setResult] = useState<{
9+
code: string;
10+
theme: "light" | "dark";
11+
svg: string | null;
12+
} | null>(null);
13+
14+
useEffect(() => {
15+
const container = containerRef.current;
16+
if (!container) return;
17+
if (typeof IntersectionObserver === "undefined") {
18+
return;
19+
}
20+
const observer = new IntersectionObserver((entries) => {
21+
if (entries.some((entry) => entry.isIntersecting)) {
22+
setVisible(true);
23+
observer.disconnect();
24+
}
25+
});
26+
observer.observe(container);
27+
return () => observer.disconnect();
28+
}, []);
29+
30+
useEffect(() => {
31+
if (!visible) return;
32+
let cancelled = false;
33+
void renderMermaidDiagram(code, theme).then(
34+
(svg) => {
35+
if (!cancelled) setResult({ code, theme, svg });
36+
},
37+
() => {
38+
if (!cancelled) setResult({ code, theme, svg: null });
39+
},
40+
);
41+
return () => {
42+
cancelled = true;
43+
};
44+
}, [code, theme, visible]);
45+
46+
const current = result?.code === code && result.theme === theme ? result : null;
47+
return (
48+
<div ref={containerRef} className="chat-markdown-mermaid">
49+
{current?.svg ? (
50+
<div
51+
role="img"
52+
aria-label="Mermaid diagram"
53+
className="overflow-x-auto p-3 [&_svg]:mx-auto [&_svg]:max-w-full"
54+
dangerouslySetInnerHTML={{ __html: current.svg }}
55+
/>
56+
) : (
57+
<>
58+
<p className="px-3 pt-2 text-xs text-muted-foreground" role="status">
59+
{current
60+
? "Unable to render this diagram. Showing source."
61+
: "Diagram preview loading…"}
62+
</p>
63+
<pre>
64+
<code className="language-mermaid">{code}</code>
65+
</pre>
66+
</>
67+
)}
68+
</div>
69+
);
70+
}

‎apps/web/src/index.css‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,32 @@ code {
18751875
background: transparent !important;
18761876
}
18771877

1878+
.chat-markdown-diff code {
1879+
display: block;
1880+
min-width: fit-content;
1881+
}
1882+
1883+
.chat-markdown-diff [data-diff-line] {
1884+
display: block;
1885+
min-height: 1lh;
1886+
}
1887+
1888+
.chat-markdown-diff [data-diff-line="added"] {
1889+
background: color-mix(in srgb, var(--color-green-500) 15%, transparent);
1890+
color: light-dark(var(--color-green-800), var(--color-green-300));
1891+
}
1892+
1893+
.chat-markdown-diff [data-diff-line="removed"] {
1894+
background: color-mix(in srgb, var(--color-red-500) 15%, transparent);
1895+
color: light-dark(var(--color-red-800), var(--color-red-300));
1896+
}
1897+
1898+
.chat-markdown-diff [data-diff-line="header"],
1899+
.chat-markdown-diff [data-diff-line="hunk"] {
1900+
color: var(--muted-foreground);
1901+
background: color-mix(in srgb, var(--foreground) 5%, transparent);
1902+
}
1903+
18781904
/* Diagnostics-style tables: row separators only, uppercase headers, and a
18791905
scroll-fade container for horizontal overflow. The root chat-markdown
18801906
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single

0 commit comments

Comments
 (0)