Skip to content
Draft
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 desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"lucide-react": "^1.0.0",
"mermaid": "^11.16.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
Expand Down
22 changes: 17 additions & 5 deletions desktop/src/shared/ui/markdown/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Button } from "@/shared/ui/button";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";

import { MermaidDiagram } from "./MermaidDiagram";
import { getReactNodeText } from "./utils";

let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
Expand Down Expand Up @@ -64,13 +65,24 @@ function getCodeBlockText(children: React.ReactNode) {
return getReactNodeText(children).replace(/\n$/, "");
}

export function MarkdownCodeBlock({
children,
language,
}: {
type MarkdownCodeBlockProps = {
children?: React.ReactNode;
language?: string;
}) {
};

export function MarkdownCodeBlock(props: MarkdownCodeBlockProps) {
const fallback = <SourceCodeBlock {...props} />;
return props.language?.toLowerCase() === "mermaid" ? (
<MermaidDiagram
fallback={fallback}
source={getCodeBlockText(props.children)}
/>
) : (
fallback
);
}

function SourceCodeBlock({ children, language }: MarkdownCodeBlockProps) {
const [isCopying, setIsCopying] = React.useState(false);
const codeBlockRef = React.useRef<HTMLPreElement | null>(null);
const code = React.useMemo(() => getCodeBlockText(children), [children]);
Expand Down
106 changes: 106 additions & 0 deletions desktop/src/shared/ui/markdown/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import * as React from "react";

import { useTheme } from "@/shared/theme/ThemeProvider";

const MAX_MERMAID_SOURCE_LENGTH = 20_000;
const MAX_MERMAID_EDGES = 200;

let mermaidModulePromise: Promise<typeof import("mermaid")> | null = null;
// Mermaid configuration is global, so keep each initialize/render pair atomic.
let renderQueue: Promise<void> = Promise.resolve();

function loadMermaid() {
mermaidModulePromise ??= import("mermaid");
return mermaidModulePromise;
}

function renderMermaid(renderId: string, source: string, isDark: boolean) {
const result = renderQueue.then(async () => {
const { default: mermaid } = await loadMermaid();
mermaid.initialize({
maxEdges: MAX_MERMAID_EDGES,
maxTextSize: MAX_MERMAID_SOURCE_LENGTH,
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: isDark ? "dark" : "default",
});
return mermaid.render(renderId, source);
});
renderQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}

export function MermaidDiagram({
fallback,
source,
}: {
fallback: React.ReactNode;
source: string;
}) {
const { isDark } = useTheme();
const reactId = React.useId();
const renderId = React.useMemo(
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
const [failed, setFailed] = React.useState(false);
const canRender =
source.trim().length > 0 && source.length <= MAX_MERMAID_SOURCE_LENGTH;

React.useEffect(() => {
if (!canRender) return;

let cancelled = false;
let objectUrl: string | null = null;
setFailed(false);
setImageUrl(null);

async function renderDiagram() {
try {
const { svg } = await renderMermaid(renderId, source, isDark);
if (cancelled) return;

objectUrl = URL.createObjectURL(
new Blob([svg], { type: "image/svg+xml" }),
);
setImageUrl(objectUrl);
} catch {
if (!cancelled) setFailed(true);
}
}

void renderDiagram();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [canRender, isDark, renderId, source]);

if (!canRender || failed) return fallback;

return (
<div
aria-busy={!imageUrl}
className="flex min-h-24 max-w-full items-center justify-center overflow-auto rounded-2xl border border-border/70 bg-muted/30 p-3"
data-mermaid-diagram=""
>
{imageUrl ? (
<img
alt="Mermaid diagram"
className="max-h-[600px] max-w-full"
draggable={false}
src={imageUrl}
/>
) : (
<span className="text-xs text-muted-foreground" role="status">
Rendering diagram…
</span>
)}
</div>
);
}
45 changes: 45 additions & 0 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,51 @@ test("code block shows language label when language is specified", async ({
await expect(codeBlock.getByText("typescript")).toBeVisible();
});

test("Mermaid fences render diagrams and invalid input falls back to code", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);

await page.evaluate(() => {
const createdAt = Math.floor(Date.now() / 1000);
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content:
"Architecture diagram\n\n```mermaid\nflowchart LR\n Browser --> Relay\n Relay --> Agent\n```",
createdAt,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content:
"Broken diagram\n\n```mermaid\nthis is not valid Mermaid syntax\n```",
createdAt: createdAt + 1,
});
});

const validRow = page
.getByTestId("message-row")
.filter({ hasText: "Architecture diagram" });
const invalidRow = page
.getByTestId("message-row")
.filter({ hasText: "Broken diagram" });

await expect(validRow.locator("[data-mermaid-diagram]")).toBeVisible();
await expect(
validRow.getByRole("img", { name: "Mermaid diagram" }),
).toBeVisible();
await expect(validRow.locator("[data-code-block]")).toHaveCount(0);

const fallback = invalidRow.locator("[data-code-block]");
await expect(fallback).toBeVisible();
await expect(fallback.getByText("mermaid", { exact: true })).toBeVisible();
await expect(fallback).toContainText("this is not valid Mermaid syntax");
});

test("typing triple backticks and Enter creates a code block in composer", async ({
page,
}) => {
Expand Down
Loading