Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9286289
fix(web): render Mermaid diagrams in Markdown
Aniketvish0 Sep 4, 2026
e3457e6
fix(web): simplify MermaidDiagram state via key remount
Aniketvish0 Sep 4, 2026
e2be074
fix(web): fit Mermaid diagrams to the chat column and cache renders
Aniketvish0 Sep 4, 2026
b9b5947
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 4, 2026
1fcb0d0
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 4, 2026
27ef9a5
chore: merge upstream/main to resolve lockfile conflict
Aniketvish0 Sep 4, 2026
b1ef2ca
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 4, 2026
cb55c70
feat(web): diagram source toggle and click-to-expand mermaid popup
Aniketvish0 Sep 11, 2026
9913f07
chore: merge origin/feat/web-mermaid-diagrams to pick up upstream mai…
Aniketvish0 Sep 11, 2026
bb1880c
chore: merge upstream/main to resolve PR conflicts
Aniketvish0 Sep 11, 2026
6118d2d
fix(web): focus close control and trap tab in expanded mermaid dialog
Aniketvish0 Sep 11, 2026
c88de3e
fix(web): keep backdrop out of expanded diagram tab order
Aniketvish0 Sep 11, 2026
086a92e
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 11, 2026
c49b98d
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 13, 2026
57d8209
Merge remote-tracking branch 'upstream/main' into feat/web-mermaid-di…
Aniketvish0 Sep 14, 2026
47112eb
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 14, 2026
5ff9709
Merge branch 'main' into feat/web-mermaid-diagrams
Aniketvish0 Sep 14, 2026
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 @@ -42,6 +42,7 @@
"jszip": "3.10.1",
"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
36 changes: 36 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,3 +860,39 @@ describe("ChatMarkdown Windows file links", () => {
expect(html).not.toContain("chat-markdown-file-link");
});
});

describe("ChatMarkdown mermaid fences", () => {
it("renders mermaid fences as diagrams with a source toggle", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={"```mermaid\nflowchart TD\n A --> B\n```"} />,
);

expect(html).toContain('data-language="mermaid"');
expect(html).toContain('data-mermaid="diagram"');
expect(html).toContain("Show source");
});

it("recognizes the mmd alias", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={"```mmd\nflowchart TD\n A --> B\n```"} />,
);

expect(html).toContain('data-language="mmd"');
expect(html).toContain('data-mermaid="diagram"');
expect(html).toContain("Show source");
});

it("keeps mermaid source while the message is streaming", () => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
isStreaming
text={"```mermaid\nflowchart TD\n A --> B\n```"}
/>,
);

expect(html).toContain('data-language="mermaid"');
expect(html).not.toContain("data-mermaid");
expect(html).not.toContain("Show source");
});
});
157 changes: 117 additions & 40 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
import {
CheckIcon,
ChevronRightIcon,
Code2Icon,
CopyIcon,
EyeIcon,
FileSpreadsheetIcon,
FileTextIcon,
GlobeIcon,
Expand Down Expand Up @@ -193,6 +195,7 @@ import {
BrowserSettingsReadError,
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { MermaidDiagram } from "./MermaidDiagram";
import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";

interface ChatMarkdownProps {
Expand Down Expand Up @@ -549,6 +552,12 @@ function extractFenceLanguage(className: string | undefined): string {
return raw === "gitignore" ? "ini" : raw;
}

/** Mermaid fences render as diagrams; `mmd` is the same language under its common alias. */
export function isMermaidFenceLanguage(language: string): boolean {
const normalized = language.trim().toLowerCase();
return normalized === "mermaid" || normalized === "mmd";
}

const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i;
const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/;

Expand Down Expand Up @@ -912,19 +921,41 @@ function MarkdownCodeBlock({
language,
fenceTitle,
theme,
renderDiagram = false,
children,
}: {
code: string;
language: string;
fenceTitle: string | null;
theme: "light" | "dark";
renderDiagram?: boolean;
children: ReactNode;
}) {
const [copied, setCopied] = useState(false);
const [wrapped, setWrapped] = useState(readInitialWordWrapSetting);
const [showSource, setShowSource] = useState(false);
// Failed diagrams fall back to source silently; parse errors echo the
// raw source and read as app breakage.
const [diagramFailed, setDiagramFailed] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
const copyLabel = copied ? "Copied" : "Copy code";
const showDiagram = renderDiagram && !showSource && !diagramFailed;
const sourceToggleLabel = showSource ? "Show diagram" : "Show source";

// Reset the toggle on fence edits, gated so streaming blocks (re-rendered
// per token) pay no state updates here.
const [lastCode, setLastCode] = useState(code);
if (renderDiagram && lastCode !== code) {
setLastCode(code);
setShowSource(false);
setDiagramFailed(false);
}

const handleDiagramError = useCallback(() => {
setDiagramFailed(true);
setShowSource(true);
}, []);

const handleCopy = useCallback(() => {
if (typeof navigator === "undefined" || navigator.clipboard == null) {
Expand Down Expand Up @@ -968,6 +999,7 @@ function MarkdownCodeBlock({
<div
className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-[var(--radius)] border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
data-language={language}
data-mermaid={renderDiagram ? (showDiagram ? "diagram" : "source") : undefined}
data-wrap={wrapped ? "true" : "false"}
>
<div className="chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none">
Expand All @@ -979,24 +1011,53 @@ function MarkdownCodeBlock({
/>
</span>
<span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action"
aria-pressed={wrapped}
onClick={() => setWrapped((value) => !value)}
aria-label={wrapLabel}
/>
}
>
<WrapTextIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">{wrapLabel}</TooltipPopup>
</Tooltip>
{renderDiagram ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action"
aria-pressed={showSource}
onClick={() => {
if (showSource) {
setDiagramFailed(false);
setShowSource(false);
} else {
setShowSource(true);
}
}}
aria-label={sourceToggleLabel}
/>
}
>
{showSource ? <EyeIcon className="size-3" /> : <Code2Icon className="size-3" />}
</TooltipTrigger>
<TooltipPopup side="top">{sourceToggleLabel}</TooltipPopup>
</Tooltip>
) : null}
{showDiagram ? null : (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action chat-markdown-wrap-action"
aria-pressed={wrapped}
onClick={() => setWrapped((value) => !value)}
aria-label={wrapLabel}
/>
}
>
<WrapTextIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">{wrapLabel}</TooltipPopup>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
Expand All @@ -1016,7 +1077,18 @@ function MarkdownCodeBlock({
</Tooltip>
</span>
</div>
{children}
{showDiagram ? (
<MermaidDiagram
key={`${theme}:${code}`}
code={code}
language={language}
theme={theme}
fallback={children}
onError={handleDiagramError}
/>
) : (
children
)}
</div>
);
}
Expand Down Expand Up @@ -3185,34 +3257,39 @@ const CHAT_MARKDOWN_COMPONENTS = {

const language = extractFenceLanguage(codeBlock.className);
const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
const codeFallback = (
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
fallback={<pre {...props}>{children}</pre>}
>
{/* 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>
);
const renderMermaid = !isStreaming && isMermaidFenceLanguage(language);
return (
<MarkdownCodeBlock
code={codeBlock.code}
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
renderDiagram={renderMermaid}
>
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
fallback={<pre {...props}>{children}</pre>}
>
{/* 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>
{codeFallback}
</MarkdownCodeBlock>
);
},
Expand Down
Loading
Loading