Skip to content
Open
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 @@ -30,6 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"mermaid": "^11.15.0",
"prism-react-renderer": "^2.4.1",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function ChatMessageContent({
) : null}
<Markdown
artifactBaseUrl={apiUrl(`/api/sessions/${sessionId}/files`)}
isStreaming={isStreaming}
bundleId={bundleId}
onSendPrompt={onSendPrompt}
onOpenArtifact={onOpenArtifact}
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/shared/lib/markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { cn } from "@/shared/lib/utils";

import { CodeBlock } from "./CodeBlock";
import { MermaidDiagram } from "./MermaidDiagram";

/**
* Matches a bundle-UI message token's language class, e.g.
Expand Down Expand Up @@ -65,6 +66,13 @@ interface MarkdownProps {
* left untouched. When set, relative `a` links render as artifact chips.
*/
artifactBaseUrl?: string;
/**
* Whether the message this markdown belongs to is still receiving streamed
* deltas. Forwarded to blocks that must not render half-written source (e.g.
* `mermaid` diagrams, which only parse/render once streaming completes).
* @default false
*/
isStreaming?: boolean;
/**
* The agent bundle this session was created from, if any. When set, fenced
* `tangent-ui:<name>` blocks the agent emits render that bundle's sandboxed
Expand Down Expand Up @@ -121,6 +129,7 @@ interface MarkdownProps {
*/
interface MarkdownComponentsOptions {
artifactBaseUrl?: string;
isStreaming: boolean;
size: MarkdownSize;
tone: MarkdownTone;
bundleId?: string;
Expand Down Expand Up @@ -540,8 +549,14 @@ function MdImage({ src, alt, title }: ImageProps) {
}

function MdCode({ className, children, node }: CodeProps) {
const { bundleId, onSendPrompt, sessionId, messageId, onCollapse } =
useMarkdownOptions();
const {
bundleId,
isStreaming,
onSendPrompt,
sessionId,
messageId,
onCollapse,
} = useMarkdownOptions();

// Bundle-UI message token: render the bundle's sandboxed component when we
// know which bundle to load it from; otherwise treat it as code.
Expand Down Expand Up @@ -569,6 +584,15 @@ function MdCode({ className, children, node }: CodeProps) {

const match = className?.match(/language-(\w+)/);

if (match?.[1] === "mermaid") {
return (
<MermaidDiagram
code={String(children).replace(/\n$/, "")}
isStreaming={isStreaming}
/>
);
}

if (match) {
const code = String(children).replace(/\n$/, "");
return (
Expand Down Expand Up @@ -637,6 +661,7 @@ export function Markdown({
size = "sm",
tone = "inherit",
artifactBaseUrl,
isStreaming = false,
bundleId,
onSendPrompt,
onOpenArtifact,
Expand All @@ -648,6 +673,7 @@ export function Markdown({
}: MarkdownProps) {
const options: MarkdownComponentsOptions = {
artifactBaseUrl,
isStreaming,
size,
tone,
bundleId,
Expand Down
145 changes: 145 additions & 0 deletions apps/web/src/shared/lib/markdown/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// local primitive — renders a Mermaid diagram as raw SVG injected into a
// wrapper element, so the scoped Tailwind classes here are an allowed escape
// hatch (not Tangle UI primitives).
import { useLayoutEffect, useRef, useState } from "react";

import { useTheme } from "@/shared/theme/themeContext";
import { HoverReveal } from "@/shared/ui/patterns/hover-reveal";
import { IconButton } from "@/shared/ui/patterns/icon-button";

import { CodeBlock } from "./CodeBlock";
import { MermaidFullscreen } from "./MermaidFullscreen";
import { MermaidLoupe } from "./MermaidLoupe";
import { mermaidThemeFor, useMermaidSvg } from "./useMermaidSvg";

/** Fractions of the viewport the inline diagram may occupy in each dimension. */
const MAX_WIDTH_FRACTION = 0.9;
const MAX_HEIGHT_FRACTION = 0.85;

/**
* Sizes the rendered diagram up to its natural dimensions, capped to a fraction
* of the viewport in *both* width and height, so it never overflows one screen
* in either direction. Overrides mermaid's `max-width` (which let the narrow
* chat bubble shrink it below readability) and re-fits on window resize. The
* fullscreen viewer remains available for anything larger.
*/
function useFitToScreen(
svgRef: React.RefObject<HTMLDivElement | null>,
svg: string,
) {
useLayoutEffect(() => {
const el = svgRef.current?.querySelector<SVGSVGElement>("svg");
const box = el?.viewBox.baseVal;
if (!el || !box || box.width === 0 || box.height === 0) return;

const node = el;
const vbWidth = box.width;
const vbHeight = box.height;

function fit() {
const maxWidth = window.innerWidth * MAX_WIDTH_FRACTION;
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
const scale = Math.min(1, maxWidth / vbWidth, maxHeight / vbHeight);
node.style.maxWidth = "none";
node.style.width = `${vbWidth * scale}px`;
node.style.height = `${vbHeight * scale}px`;
}

fit();
window.addEventListener("resize", fit);
return () => window.removeEventListener("resize", fit);
}, [svgRef, svg]);
}

interface MermaidDiagramProps {
code: string;
/**
* Whether the source is still streaming in. While true, the block may be a
* half-written, transiently-valid diagram, so rendering is deferred until
* streaming completes — this avoids the diagram/code-block flicker.
*/
isStreaming?: boolean;
}

/**
* Renders a fenced ```mermaid block as an SVG diagram. Mermaid is loaded lazily
* and re-rendered when the source or active theme changes.
*
* Agent output streams in token-by-token, so the source is frequently
* incomplete mid-stream. Rendering is gated on streaming completion (see
* {@link useMermaidSvg}); until a diagram is available the raw source is shown
* as a code block, and on a hard failure the parse error is shown alongside it.
*
* Once rendered, the diagram exposes a hover toolbar with a magnifier loupe and
* a fullscreen zoom/pan viewer for dense, hard-to-read diagrams.
*/
export function MermaidDiagram({
code,
isStreaming = false,
}: MermaidDiagramProps) {
const { theme } = useTheme();
const svgRef = useRef<HTMLDivElement>(null);
const [loupeOn, setLoupeOn] = useState(false);
const [fullscreen, setFullscreen] = useState(false);

const { svg, failed, errorText } = useMermaidSvg({
code,
theme: mermaidThemeFor(theme),
enabled: !isStreaming,
});

useFitToScreen(svgRef, svg ?? "");

if (svg == null) {
return (
<div>
{failed && errorText ? (
<pre className="my-1 overflow-auto rounded-md bg-red-950 p-2 text-xs text-red-200">
mermaid error: {errorText}
</pre>
) : null}
<CodeBlock
code={code}
language="mermaid"
showLineNumbers={false}
className="my-1 h-auto max-h-64 rounded-md text-xs"
/>
</div>
);
}

return (
<div className="group relative my-2">
<div className="overflow-auto">
<div className="relative mx-auto w-fit">
<div ref={svgRef} dangerouslySetInnerHTML={{ __html: svg }} />
{loupeOn ? <MermaidLoupe svg={svg} targetRef={svgRef} /> : null}
</div>
</div>

<div className="absolute top-1.5 right-1.5 z-20">
<HoverReveal>
<div className="flex gap-0.5 rounded-md border border-border bg-background/80 p-0.5 backdrop-blur">
<IconButton
icon="Search"
aria-label={loupeOn ? "Turn off magnifier" : "Magnify diagram"}
aria-pressed={loupeOn}
variant={loupeOn ? "subtle" : "ghost"}
onClick={() => setLoupeOn((v) => !v)}
/>
<IconButton
icon="Maximize2"
aria-label="Open diagram fullscreen"
variant="ghost"
onClick={() => setFullscreen(true)}
/>
</div>
</HoverReveal>
</div>

{fullscreen ? (
<MermaidFullscreen svg={svg} onClose={() => setFullscreen(false)} />
) : null}
</div>
);
}
Loading
Loading