Skip to content
Closed
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 @@ -40,6 +40,7 @@
"jszip": "3.10.1",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"mermaid": "^11.17.2",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
Expand Down
155 changes: 123 additions & 32 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useAtomValue } from "@effect/atom-react";
import {
ChartNetworkIcon,
CheckIcon,
ChevronRightIcon,
CodeIcon,
CopyIcon,
FileSpreadsheetIcon,
FileTextIcon,
Expand Down Expand Up @@ -121,6 +123,8 @@ import { LRUCache } from "../lib/lruCache";
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { GitHubIcon } from "./Icons";
import { RenderErrorBoundary } from "./RenderErrorBoundary";
import { MermaidDiagram } from "./chat/MermaidDiagram";
import { isMermaidFenceLanguage } from "../lib/mermaid";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings, useClientSettings } from "../hooks/useSettings";
import {
Expand Down Expand Up @@ -203,6 +207,9 @@ interface ChatMarkdownProps {
imageBaseDir?: string | undefined;
onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
/** Render `mermaid` fences as diagrams. On for GitHub-authored bodies, which their authors
write expecting GitHub to draw them; off elsewhere, where a fence is code to read. */
mermaidDiagrams?: boolean;
}

export function canUseMarkdownFileShellActions(
Expand Down Expand Up @@ -885,12 +892,18 @@ function MarkdownCodeBlock({
language,
fenceTitle,
theme,
actions,
showWrapToggle = true,
children,
}: {
code: string;
language: string;
fenceTitle: string | null;
theme: "light" | "dark";
/** Extra toolbar controls, shown before the block's own actions. */
actions?: ReactNode;
/** Off for bodies line wrapping cannot affect, such as a rendered diagram. */
showWrapToggle?: boolean;
children: ReactNode;
}) {
const [copied, setCopied] = useState(false);
Expand Down Expand Up @@ -952,24 +965,27 @@ 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>
{actions}
{showWrapToggle ? (
<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>
) : null}
<Tooltip>
<TooltipTrigger
render={
Expand All @@ -994,6 +1010,59 @@ function MarkdownCodeBlock({
);
}

/**
* A `mermaid` fence: the diagram by default, as GitHub shows it, with a toggle back to the
* source so nothing this block used to show is lost.
*/
function MarkdownMermaidBlock({
code,
language,
fenceTitle,
theme,
source,
}: {
code: string;
language: string;
fenceTitle: string | null;
theme: "light" | "dark";
source: ReactNode;
}) {
const [showSource, setShowSource] = useState(false);
const toggleLabel = showSource ? "Show diagram" : "Show diagram source";

return (
<MarkdownCodeBlock
code={code}
language={language}
fenceTitle={fenceTitle}
theme={theme}
showWrapToggle={showSource}
actions={
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action"
aria-pressed={showSource}
onClick={() => setShowSource((value) => !value)}
aria-label={toggleLabel}
/>
}
>
{showSource ? <ChartNetworkIcon className="size-3" /> : <CodeIcon className="size-3" />}
</TooltipTrigger>
<TooltipPopup side="top">{toggleLabel}</TooltipPopup>
</Tooltip>
}
>
{showSource ? source : <MermaidDiagram code={code} theme={theme} source={source} />}
</MarkdownCodeBlock>
);
}

interface SuspenseShikiCodeBlockProps {
className: string | undefined;
code: string;
Expand Down Expand Up @@ -2167,6 +2236,7 @@ function useChatMarkdownState({
onUseArtifactTemplate,
imageBaseDir,
onImageExpand,
mermaidDiagrams = false,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
Expand Down Expand Up @@ -2577,6 +2647,7 @@ function useChatMarkdownState({
isStreaming,
linkTargetPreference,
markdownFileLinkMetaByHref,
mermaidDiagrams,
onTaskListChange,
onUseArtifactTemplate,
openChangeRequestLink,
Expand All @@ -2603,6 +2674,7 @@ function useChatMarkdownState({
isStreaming,
linkTargetPreference,
markdownFileLinkMetaByHref,
mermaidDiagrams,
onTaskListChange,
onUseArtifactTemplate,
openChangeRequestLink,
Expand Down Expand Up @@ -3064,34 +3136,53 @@ const CHAT_MARKDOWN_COMPONENTS = {
return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
},
pre: function MarkdownPre({ node, children, ...props }) {
const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);
const { resolvedTheme, diffThemeName, isStreaming, mermaidDiagrams } = use(
ChatMarkdownRendererContext,
);
const codeBlock = extractCodeBlock(children);
if (!codeBlock) {
return <pre {...props}>{children}</pre>;
}

const language = extractFenceLanguage(codeBlock.className);
const fenceTitle = extractFenceTitle(extractPreCodeMeta(node));
const source = (
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
fallback={<pre {...props}>{children}</pre>}
>
<Suspense fallback={<pre {...props}>{children}</pre>}>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
);

// A half-written diagram cannot parse, so streaming text keeps showing its source.
if (mermaidDiagrams && !isStreaming && isMermaidFenceLanguage(language)) {
return (
<MarkdownMermaidBlock
code={codeBlock.code}
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
source={source}
/>
);
}

return (
<MarkdownCodeBlock
code={codeBlock.code}
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
>
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
fallback={<pre {...props}>{children}</pre>}
>
<Suspense fallback={<pre {...props}>{children}</pre>}>
<SuspenseShikiCodeBlock
className={codeBlock.className}
code={codeBlock.code}
themeName={diffThemeName}
isStreaming={isStreaming}
/>
</Suspense>
</RenderErrorBoundary>
{source}
</MarkdownCodeBlock>
);
},
Expand Down
100 changes: 100 additions & 0 deletions apps/web/src/components/chat/MermaidDiagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { TriangleAlertIcon } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";

import { getCachedMermaidSvg, renderMermaidSvg, type MermaidTheme } from "~/lib/mermaid";

type DiagramState =
| { status: "pending" }
| { status: "ready"; svg: string }
| { status: "failed"; message: string };

/**
* Mermaid parse errors carry a multi-line caret diagram that is useless in a one-line note,
* so only its first line survives.
*/
function diagramFailureMessage(cause: unknown): string {
const message = cause instanceof Error ? cause.message : String(cause);
const firstLine = message.split("\n", 1)[0]?.trim() ?? "";
if (firstLine.length === 0) return "";
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine;
}

/**
* A `mermaid` fence rendered as a diagram, the way GitHub renders one. Falls back to the
* highlighted source when mermaid cannot parse the diagram or its chunk fails to load —
* a broken diagram should still show what the author wrote.
*/
export function MermaidDiagram({
code,
theme,
source,
}: {
code: string;
theme: MermaidTheme;
/** The fence's highlighted source, shown instead of the diagram when rendering fails. */
source: ReactNode;
}) {
const [resolved, setResolved] = useState<{ key: string; state: DiagramState } | null>(null);

// A cache hit renders the diagram on the first frame — the common case, since scrolling a
// long pull request body remounts every diagram in it.
const cached = getCachedMermaidSvg(code, theme);
const key = `${theme}\n${code}`;
const state: DiagramState =
cached != null
? { status: "ready", svg: cached }
: resolved?.key === key
? resolved.state
: { status: "pending" };

useEffect(() => {
// Keyed off what the render pass read, not a fresh lookup: a cache write landing between
// render and here would otherwise skip the render and strand the placeholder, since the
// cache cannot tell React that it changed.
if (cached != null) return;

let cancelled = false;
void renderMermaidSvg(code, theme).then(
(svg) => {
if (!cancelled) setResolved({ key, state: { status: "ready", svg } });
},
(cause: unknown) => {
if (!cancelled) {
setResolved({ key, state: { status: "failed", message: diagramFailureMessage(cause) } });
}
},
);
return () => {
cancelled = true;
};
}, [cached, code, key, theme]);

if (state.status === "pending") {
return <p className="px-3 pt-1 pb-3 text-xs text-muted-foreground">Rendering diagram…</p>;
}

if (state.status === "failed") {
return (
<>
<p className="flex items-start gap-1.5 px-3 pt-1 pb-2 text-xs text-muted-foreground">
<TriangleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0" />
<span>
Unable to render this diagram.
{state.message.length > 0 ? ` ${state.message}` : null}
</span>
</p>
{source}
</>
);
}

return (
<div
role="img"
aria-label="Mermaid diagram"
className="chat-markdown-mermaid flex justify-center overflow-x-auto px-3 pt-1 pb-3"
// Mermaid runs its own DOMPurify pass at the `strict` security level this app configures.
dangerouslySetInnerHTML={{ __html: state.svg }}
/>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export const PullRequestMarkdownContext = createContext<string | null>(null);

/**
* A pull request body, rendered with the app's markdown renderer plus a card for each upload
* embedded in it, which that renderer drops on the floor.
* embedded in it, which that renderer drops on the floor. `mermaid` fences render as diagrams
* here because GitHub renders them, so authors write them expecting a diagram.
*
* These upload URLs do not identify the media format. The card links to GitHub, where the
* original upload can be opened or downloaded even when its codec cannot play in the client.
Expand Down Expand Up @@ -49,6 +50,7 @@ export function PullRequestMarkdown({
threadRef={threadRef ?? undefined}
environmentId={environmentId}
extraRemarkPlugins={extraRemarkPlugins}
mermaidDiagrams
/>
);
}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1865,6 +1865,13 @@ code {
background: transparent !important;
}

/* Mermaid sizes its SVG from the diagram's own layout, which overflows a narrow right panel.
Let it shrink to the panel and scroll only when its own minimum width wins. */
.chat-markdown .chat-markdown-mermaid svg {
max-width: 100%;
height: auto;
}

/* Diagnostics-style tables: row separators only, uppercase headers, and a
scroll-fade container for horizontal overflow. The root chat-markdown
wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
Expand Down
Loading
Loading