Skip to content

Commit 1f6b45c

Browse files
committed
Render Mermaid diagrams, and keep the sidebar search smooth on large histories
- Mermaid: a completed ```mermaid code block now renders as an actual diagram (mermaid.js, lazy-loaded so it does not add to every app boot), matching the existing "Convert to Mermaid diagram" analyze preset which previously produced text that just sat there highlighted as code. Streams as plain code while the message is still generating, since partial Mermaid syntax is invalid by definition, then swaps to the live diagram once done. Invalid syntax degrades to an inline error card instead of mermaid's default behavior of leaking a stray error SVG into document.body. - Sidebar search: full-text search scans every message of every chat, which can get slow with a large history. The query now runs through useDeferredValue so typing never blocks on it, and per-project filtering is precomputed into a single memoized map instead of re-scanning per project on every render. - react-markdown was rebuilding its entire processor pipeline on every streamed token because its plugin arrays/components object were recreated each render; hoisted them to stable references.
1 parent 0602bbf commit 1f6b45c

7 files changed

Lines changed: 1373 additions & 152 deletions

File tree

frontend/package-lock.json

Lines changed: 1206 additions & 117 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"fast-glob": "^3.3.3",
2121
"highlight.js": "^11.11.1",
2222
"lucide-react": "^1.24.0",
23+
"mermaid": "^11.16.0",
2324
"react": "^19.2.7",
2425
"react-dom": "^19.2.7",
2526
"react-markdown": "^10.1.0",

frontend/src/components/layout.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useDeferredValue, useEffect, useMemo, useState } from "react";
22
import { Outlet, useNavigate, useParams } from "react-router-dom";
33
import {
44
BookMarked,
@@ -566,7 +566,11 @@ export default function Layout() {
566566
};
567567
}, [hasApi]);
568568

569-
const query = search.trim().toLowerCase();
569+
// Filtering re-scans every message of every chat, which can get expensive
570+
// with a large history — defer it a frame behind the input so typing in
571+
// the search box never feels laggy, even while the filter itself is slow.
572+
const deferredSearch = useDeferredValue(search);
573+
const query = deferredSearch.trim().toLowerCase();
570574
const matchesSearch = (s: ChatSession) =>
571575
(!query ||
572576
s.title.toLowerCase().includes(query) ||
@@ -600,6 +604,19 @@ export default function Layout() {
600604
query,
601605
activeTags,
602606
]);
607+
/* eslint-disable react-hooks/exhaustive-deps --
608+
matchesSearch is a plain function derived from `query`/`activeTags`, already listed below */
609+
const sessionsByProject = useMemo(() => {
610+
const map = new Map<string, ChatSession[]>();
611+
for (const s of sessions) {
612+
if (!s.projectId || !matchesSearch(s)) continue;
613+
const list = map.get(s.projectId);
614+
if (list) list.push(s);
615+
else map.set(s.projectId, [s]);
616+
}
617+
return map;
618+
}, [sessions, query, activeTags]);
619+
/* eslint-enable react-hooks/exhaustive-deps */
603620

604621
async function handleNewChat(projectId?: string) {
605622
const session = await createSession(null, projectId ?? null);
@@ -761,7 +778,7 @@ export default function Layout() {
761778
<ProjectGroup
762779
key={project.id}
763780
project={project}
764-
sessions={sessions.filter((s) => s.projectId === project.id && matchesSearch(s))}
781+
sessions={sessionsByProject.get(project.id) ?? []}
765782
activeSessionId={sessionId}
766783
onOpenSession={(id) => navigate(`/chat/${id}`)}
767784
onDeleteSession={handleDelete}
Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import { useState } from "react";
2-
import ReactMarkdown from "react-markdown";
1+
import { memo, useMemo, useState } from "react";
2+
import ReactMarkdown, { type Components } from "react-markdown";
33
import remarkGfm from "remark-gfm";
44
import rehypeHighlight from "rehype-highlight";
55
import { Check, Copy } from "lucide-react";
66
import { cn } from "@/lib/utils";
7+
import { MermaidDiagram } from "@/components/mermaid-diagram";
78
import "highlight.js/styles/github-dark.css";
89

10+
// Hoisted to module scope: react-markdown rebuilds its unified processor
11+
// whenever these array/object references change, so keeping them stable
12+
// across renders avoids redoing that work on every streamed token.
13+
const REMARK_PLUGINS = [remarkGfm];
14+
const REHYPE_PLUGINS = [rehypeHighlight];
15+
916
function CodeBlock({ className, children }: { className?: string; children: React.ReactNode }) {
1017
const [copied, setCopied] = useState(false);
1118
const text = String(children).replace(/\n$/, "");
@@ -33,37 +40,49 @@ function CodeBlock({ className, children }: { className?: string; children: Reac
3340
);
3441
}
3542

36-
export function Markdown({ content }: { content: string }) {
43+
// isStreaming gates Mermaid rendering: a ```mermaid block is invalid syntax
44+
// for most of the time it's still being typed out token by token, so we show
45+
// it as a plain code block until the message is done, then swap it for the
46+
// live diagram — same pattern the agent write_file preview already uses for
47+
// its diff view.
48+
function createComponents(isStreaming: boolean): Components {
49+
return {
50+
pre: ({ children }) => <>{children}</>,
51+
code: ({ className, children, ...props }) => {
52+
const isInline = !className;
53+
if (isInline) {
54+
return (
55+
<code className="rounded bg-muted px-1 py-0.5 text-[0.85em]" {...props}>
56+
{children}
57+
</code>
58+
);
59+
}
60+
const language = className?.replace("hljs language-", "").replace("language-", "").trim();
61+
if (language === "mermaid" && !isStreaming) {
62+
return <MermaidDiagram code={String(children).replace(/\n$/, "")} />;
63+
}
64+
return <CodeBlock className={className}>{children}</CodeBlock>;
65+
},
66+
a: ({ className, ...props }) => (
67+
<a
68+
className={cn(className, "underline underline-offset-2")}
69+
target="_blank"
70+
rel="noopener noreferrer"
71+
{...props}
72+
/>
73+
),
74+
};
75+
}
76+
77+
const STATIC_COMPONENTS = createComponents(false);
78+
79+
export const Markdown = memo(function Markdown({ content, isStreaming = false }: { content: string; isStreaming?: boolean }) {
80+
const components = useMemo(() => (isStreaming ? createComponents(true) : STATIC_COMPONENTS), [isStreaming]);
3781
return (
3882
<div className="prose-chat">
39-
<ReactMarkdown
40-
remarkPlugins={[remarkGfm]}
41-
rehypePlugins={[rehypeHighlight]}
42-
components={{
43-
pre: ({ children }) => <>{children}</>,
44-
code: ({ className, children, ...props }) => {
45-
const isInline = !className;
46-
if (isInline) {
47-
return (
48-
<code className="rounded bg-muted px-1 py-0.5 text-[0.85em]" {...props}>
49-
{children}
50-
</code>
51-
);
52-
}
53-
return <CodeBlock className={className}>{children}</CodeBlock>;
54-
},
55-
a: ({ className, ...props }) => (
56-
<a
57-
className={cn(className, "underline underline-offset-2")}
58-
target="_blank"
59-
rel="noopener noreferrer"
60-
{...props}
61-
/>
62-
),
63-
}}
64-
>
83+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS} components={components}>
6584
{content}
6685
</ReactMarkdown>
6786
</div>
6887
);
69-
}
88+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useEffect, useId, useState } from "react";
2+
import { AlertTriangle } from "lucide-react";
3+
import { useTheme } from "@/components/theme-provider";
4+
5+
// Loaded lazily and cached: mermaid is a large library that most sessions
6+
// never touch a diagram in, so it shouldn't add to every app boot's parse
7+
// and eval cost — only fetched the first time a ```mermaid block is seen.
8+
let mermaidPromise: Promise<typeof import("mermaid")> | null = null;
9+
function loadMermaid() {
10+
if (!mermaidPromise) mermaidPromise = import("mermaid");
11+
return mermaidPromise;
12+
}
13+
14+
function useIsDark() {
15+
const { theme } = useTheme();
16+
const [systemDark, setSystemDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches);
17+
18+
useEffect(() => {
19+
const mql = window.matchMedia("(prefers-color-scheme: dark)");
20+
const onChange = () => setSystemDark(mql.matches);
21+
mql.addEventListener("change", onChange);
22+
return () => mql.removeEventListener("change", onChange);
23+
}, []);
24+
25+
return theme === "system" ? systemDark : theme === "dark";
26+
}
27+
28+
// Only invoked once a fenced ```mermaid block's content has stopped changing
29+
// (see markdown.tsx) — mermaid.render() throws on the truncated/invalid
30+
// syntax a diagram has while it's still streaming in token by token, and on
31+
// error it also injects a stray error SVG into document.body, not just the
32+
// target element, so we validate with parse() first and never call render()
33+
// on text we know is incomplete.
34+
export function MermaidDiagram({ code }: { code: string }) {
35+
const rawId = useId();
36+
const diagramId = `mermaid-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
37+
const isDark = useIsDark();
38+
const [svg, setSvg] = useState<string | null>(null);
39+
const [error, setError] = useState<string | null>(null);
40+
41+
useEffect(() => {
42+
let cancelled = false;
43+
44+
(async () => {
45+
const { default: mermaid } = await loadMermaid();
46+
mermaid.initialize({
47+
startOnLoad: false,
48+
securityLevel: "strict",
49+
fontFamily: "inherit",
50+
theme: isDark ? "dark" : "default",
51+
});
52+
53+
const valid = await mermaid.parse(code, { suppressErrors: true });
54+
if (!valid) {
55+
if (!cancelled) setError("Couldn't parse this as a Mermaid diagram.");
56+
return;
57+
}
58+
try {
59+
const { svg: rendered } = await mermaid.render(diagramId, code);
60+
if (!cancelled) {
61+
setSvg(rendered);
62+
setError(null);
63+
}
64+
} catch {
65+
if (!cancelled) setError("Couldn't render this Mermaid diagram.");
66+
}
67+
})();
68+
69+
return () => {
70+
cancelled = true;
71+
};
72+
}, [code, diagramId, isDark]);
73+
74+
if (error) {
75+
return (
76+
<div className="my-2 flex items-center gap-1.5 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
77+
<AlertTriangle className="size-3.5 shrink-0" /> {error}
78+
</div>
79+
);
80+
}
81+
82+
if (!svg) {
83+
return <div className="my-2 h-24 animate-pulse rounded-lg bg-muted/50" />;
84+
}
85+
86+
// Mermaid's own sanitized SVG output — safe under securityLevel: "strict",
87+
// which strips script tags and dangerous attributes before we get here.
88+
return <div className="mermaid-diagram my-2 overflow-x-auto rounded-lg border border-border bg-background p-3" dangerouslySetInnerHTML={{ __html: svg }} />;
89+
}

frontend/src/pages/Chat.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,13 @@ const MessageBubble = memo(function MessageBubble({
302302
))}
303303
</div>
304304
)}
305-
{m.content ? <Markdown content={m.content} /> : isStreaming && isLastAssistant ? "…" : ""}
305+
{m.content ? (
306+
<Markdown content={m.content} isStreaming={isStreaming && isLastAssistant} />
307+
) : isStreaming && isLastAssistant ? (
308+
"…"
309+
) : (
310+
""
311+
)}
306312
</div>
307313
)}
308314
<div className="mt-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">

frontend/src/pages/Compare.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export default function Compare() {
157157
{result?.error ? (
158158
<p className="text-destructive">{result.error}</p>
159159
) : result?.content ? (
160-
<Markdown content={result.content} />
160+
<Markdown content={result.content} isStreaming={!!result.streaming} />
161161
) : result?.streaming ? (
162162
<Loader2 className="size-4 animate-spin text-muted-foreground" />
163163
) : (

0 commit comments

Comments
 (0)