From 7d9282c75178f95ba3ab170d38a3149245632fda Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 14 Oct 2026 11:40:29 +0100 Subject: [PATCH 1/2] feat(app): mermaid flowcharts and diffs in a reply draw as diagrams and diffs A mermaid block with a flowchart draws as the artifact view's diagram (now a shared FlowchartSvg), with Source in the frame's head; other Mermaid stays highlighted source with a quiet note. A diff or patch block draws with the chat's diff renderer: files named and numbered, bare +/- lines without numbers. Drafts stay text until they land, and anything that can't draw stays as it was. Co-Authored-By: Claude Opus 5.5 --- app/e2e/code-blocks.spec.ts | 130 ++++++++++++++ app/package.json | 2 +- app/src/components/art/flowchart.tsx | 91 ++++++++++ app/src/components/art/views/diagram-view.tsx | 53 +----- .../components/conversation/code-blocks.css | 27 +++ .../components/conversation/code-blocks.tsx | 75 ++++++++ .../components/conversation/code-highlight.ts | 2 +- .../components/conversation/drawn-block.tsx | 127 ++++++++++++++ app/src/components/conversation/markdown.tsx | 3 + app/src/components/diff/diffs.tsx | 7 +- app/src/lib/code-blocks.test.ts | 87 ++++++++++ app/src/lib/code-blocks.ts | 160 ++++++++++++++++++ docs/changelog.mdx | 10 ++ 13 files changed, 722 insertions(+), 52 deletions(-) create mode 100644 app/e2e/code-blocks.spec.ts create mode 100644 app/src/components/art/flowchart.tsx create mode 100644 app/src/components/conversation/code-blocks.css create mode 100644 app/src/components/conversation/code-blocks.tsx create mode 100644 app/src/components/conversation/drawn-block.tsx create mode 100644 app/src/lib/code-blocks.test.ts create mode 100644 app/src/lib/code-blocks.ts diff --git a/app/e2e/code-blocks.spec.ts b/app/e2e/code-blocks.spec.ts new file mode 100644 index 00000000..004237b2 --- /dev/null +++ b/app/e2e/code-blocks.spec.ts @@ -0,0 +1,130 @@ +import type { Page } from "@playwright/test"; + +import { expect, mockOnly, test } from "./fixtures"; + +// A reply's mermaid and diff blocks, drawn (components/conversation/code- +// blocks.tsx): a flowchart as its diagram, with its source a click away; +// other Mermaid as written; a diff as the chat's edits are drawn. A draft +// stays text until it lands. Replies land through the demo's +// window.__berthDraft (lib/mock-conversation). + +const BOX = "gpu"; +const AGENT = "judge-v2-claude"; + +const FENCE = "```"; + +const DIAGRAM_REPLY = [ + "## How a judged sample flows", + "", + `${FENCE}mermaid`, + "flowchart LR", + " A[Sample] --> B{Cached?}", + " B -->|yes| C[Reuse score]", + " B -->|no| D[Judge v2]", + " D --> E[(acme-scores)]", + FENCE, + "", + "And the order the calls go in:", + "", + `${FENCE}mermaid`, + "sequenceDiagram", + " Runner->>Judge: score(sample)", + " Judge-->>Runner: 4", + FENCE, +].join("\n"); + +const DIFF_REPLY = [ + "## The fix", + "", + `${FENCE}diff`, + "--- a/src/acme/judge.ts", + "+++ b/src/acme/judge.ts", + "@@ -12,4 +12,5 @@ export function score(sample: Sample) {", + " const rubric = loadRubric();", + "- return judge(sample, rubric);", + "+ const pairs = pairwise(sample);", + "+ return judge(pairs, rubric);", + " }", + FENCE, + "", + "Or, in short:", + "", + `${FENCE}diff`, + "- timeout: 10", + "+ timeout: 30", + " retries: 3", + FENCE, +].join("\n"); + +type DraftHook = { show(b: string, s: string, t: string): void; land(b: string, s: string, t: string): void; clear(b: string, s: string): void }; +const draftHook = (page: Page, fn: "show" | "land" | "clear", text = "") => + page.evaluate(([fn, b, s, t]) => { + const h = (window as unknown as { __berthDraft: DraftHook }).__berthDraft; + if (fn === "show") h.show(b, s, t); + else if (fn === "land") h.land(b, s, t); + else h.clear(b, s); + }, [fn, BOX, AGENT, text] as const); + +test("a mermaid flowchart in a reply draws as its diagram, with its source a click away", async ({ app }) => { + mockOnly("lands a reply on the demo's agent"); + await app.open({ params: { view: "conversation" } }); + await app.openWorktree("gpu/judge-v2"); + await draftHook(app.page, "land", DIAGRAM_REPLY); + const md = app.chat.locator("[data-kind=text] .cv-md").filter({ hasText: "How a judged sample flows" }); + const [flow, sequence] = [md.locator(".cv-pre").nth(0), md.locator(".cv-pre").nth(1)]; + + await expect(flow.locator("svg[role=img]")).toBeVisible(); + await expect(flow.locator("svg[role=img]")).toHaveAttribute("aria-label", "Diagram: Sample, Cached?, Reuse score, Judge v2, acme-scores"); + await expect(flow.locator(":scope > pre")).toBeHidden(); + await expect(flow.locator(".cv-pre-lang")).toHaveText("Mermaid"); + + await flow.getByRole("button", { name: "Source" }).click(); + await expect(flow.locator(":scope > pre")).toBeVisible(); + await expect(flow.locator(":scope > pre")).toContainText("A[Sample] --> B{Cached?}"); + await expect(flow.locator("svg[role=img]")).toBeHidden(); + await flow.getByRole("button", { name: "Diagram" }).click(); + await expect(flow.locator("svg[role=img]")).toBeVisible(); + + // Mermaid other than a flowchart is its source, said so. + await expect(sequence.locator(":scope > pre")).toBeVisible(); + await expect(sequence.locator("svg[role=img]")).toHaveCount(0); + await expect(sequence.locator(".cv-pre-note")).toHaveText("Shown as written: Shipyard draws flowcharts"); +}); + +test("a diff in a reply draws as the chat's edits do: a patch with its file, bare lines without numbers", async ({ app }) => { + mockOnly("lands a reply on the demo's agent"); + await app.open({ params: { view: "conversation" } }); + await app.openWorktree("gpu/judge-v2"); + await draftHook(app.page, "land", DIFF_REPLY); + const md = app.chat.locator("[data-kind=text] .cv-md").filter({ hasText: "The fix" }); + const [patch, bare] = [md.locator(".cv-pre").nth(0), md.locator(".cv-pre").nth(1)]; + + // The renderer loads on first use; then both blocks are drawn. + await expect(patch).toHaveAttribute("data-drawn", "diff"); + await expect(bare).toHaveAttribute("data-drawn", "diff"); + await expect(patch.locator(".cv-pre-lang")).toHaveText("Diff"); + await expect(patch.locator(".cv-pre-file")).toContainText("src/acme/judge.ts"); + await expect(patch.locator(".cv-pre-file")).toContainText("+2"); + await expect(patch.locator(".cv-pre-file")).toContainText("−1"); + await expect(patch.locator(":scope > pre")).toBeHidden(); + // Numbered by the file's own lines; the bare lines number nothing. + await expect(patch.locator("[data-column-number]").first()).toBeVisible(); + await expect(patch.locator("[data-column-number]", { hasText: /^12$/ }).first()).toBeVisible(); + await expect(bare.locator("pre[data-diff]")).toHaveAttribute("data-disable-line-numbers", ""); + await expect(patch.locator("pre[data-diff]")).not.toHaveAttribute("data-disable-line-numbers"); + await expect(bare.locator(".cv-pre-file")).not.toContainText("change"); +}); + +test("a reply still being written keeps its diagram as text until it lands", async ({ app }) => { + mockOnly("drives the demo's screen"); + await app.open({ params: { view: "conversation" } }); + await app.openWorktree("gpu/judge-v2"); + await draftHook(app.page, "show", DIAGRAM_REPLY); + const draft = app.chat.locator("[data-draft] .cv-md").filter({ hasText: "How a judged sample flows" }); + await expect(draft.locator(".cv-pre").first().locator("pre")).toBeVisible(); + await expect(draft.locator("svg[role=img]")).toHaveCount(0); + await draftHook(app.page, "land", DIAGRAM_REPLY); + await draftHook(app.page, "clear"); + const md = app.chat.locator("[data-kind=text] .cv-md").filter({ hasText: "How a judged sample flows" }); + await expect(md.locator(".cv-pre").first().locator("svg[role=img]")).toBeVisible(); +}); diff --git a/app/package.json b/app/package.json index 677755c9..617d7c42 100644 --- a/app/package.json +++ b/app/package.json @@ -23,7 +23,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts src/lib/code-blocks.test.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/components/art/flowchart.tsx b/app/src/components/art/flowchart.tsx new file mode 100644 index 00000000..4d7ddfba --- /dev/null +++ b/app/src/components/art/flowchart.tsx @@ -0,0 +1,91 @@ +import { type CSSProperties, useId } from "react"; + +import type { Flowchart, Layout } from "@/lib/art/mermaid"; + +const PAD = 14; + +// edgePath is an edge's curve, and its ends: an edge back up the flow +// loops out below the nodes it joins. +function edgePath(layout: Layout, a: { x: number; y: number }, b: { x: number; y: number }) { + const { NW, NH, lr } = layout; + const [x0, y0, x1, y1] = lr ? [a.x + NW, a.y + NH / 2, b.x - 2, b.y + NH / 2] : [a.x + NW / 2, a.y + NH, b.x + NW / 2, b.y - 2]; + const back = lr ? x1 < x0 : y1 < y0; + const c = back ? [x0 + 60, y0 + 70, x1 - 60, y1 + 70] : lr ? [(x0 + x1) / 2, y0, (x0 + x1) / 2, y1] : [x0, (y0 + y1) / 2, x1, (y0 + y1) / 2]; + return { x0, y0, x1, y1, back, c, d: `M${x0},${y0} C${c[0]},${c[1]} ${c[2]},${c[3]} ${x1},${y1}` }; +} + +// flowchartBox is the drawing's viewBox: the nodes, with room around +// them, and with fit, the loops of edges back up the flow too, which an +// artifact's diagram leaves to its frame to cut. +export function flowchartBox(g: Flowchart, layout: Layout, fit = false): { x: number; y: number; w: number; h: number } { + let [x0, y0, x1, y1] = [0, 0, layout.W, layout.H]; + if (fit) + for (const e of g.edges) { + const a = layout.pos[e.from]; + const b = layout.pos[e.to]; + if (!a || !b) continue; + const p = edgePath(layout, a, b); + if (!p.back) continue; + // The curve's extent, sampled: near enough for a margin. + for (let t = 0; t <= 1; t += 0.05) { + const u = 1 - t; + const x = u * u * u * p.x0 + 3 * u * u * t * p.c[0] + 3 * u * t * t * p.c[2] + t * t * t * p.x1; + const y = u * u * u * p.y0 + 3 * u * u * t * p.c[1] + 3 * u * t * t * p.c[3] + t * t * t * p.y1; + [x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)]; + } + } + return { x: x0 - PAD, y: y0 - PAD, w: x1 - x0 + PAD * 2, h: y1 - y0 + PAD * 2 }; +} + +// FlowchartSvg draws a Mermaid flowchart laid out by lib/art/mermaid.ts in +// the theme's chart colours (--chart-*, set by an .art-scope around it): +// an artifact's diagram, and a mermaid block in a reply. A thumb is a +// card's small picture: bolder lines, no edge labels. fit: flowchartBox. +export function FlowchartSvg({ g, layout, thumb = false, fit = false, className, style }: { g: Flowchart; layout: Layout; thumb?: boolean; fit?: boolean; className?: string; style?: CSSProperties }) { + const arrow = `art-arrow-${useId().replace(/:/g, "")}`; + const { pos, NW, NH } = layout; + const box = flowchartBox(g, layout, fit); + return ( + n.label).join(", ")}`}> + + + + + + {g.edges.map((e, i) => { + const a = pos[e.from]; + const b = pos[e.to]; + if (!a || !b) return null; + const { x0, y0, x1, y1, d } = edgePath(layout, a, b); + return ( + + + {e.label && !thumb && ( + + + + {e.label} + + + )} + + ); + })} + {g.nodes.map((n, i) => { + const p = pos[n.id]; + const store = n.shape === "store"; + const tint = store ? "var(--chart-3)" : n.shape === "diamond" ? "var(--chart-4)" : i === 0 ? "var(--chart-1)" : undefined; + const r = n.shape === "round" || n.shape === "circle" ? NH / 2 : n.shape === "diamond" ? 4 : 8; + return ( + + + {store && } + + {n.label.length > 26 ? `${n.label.slice(0, 25)}…` : n.label} + + + ); + })} + + ); +} diff --git a/app/src/components/art/views/diagram-view.tsx b/app/src/components/art/views/diagram-view.tsx index 2d903914..3a171f71 100644 --- a/app/src/components/art/views/diagram-view.tsx +++ b/app/src/components/art/views/diagram-view.tsx @@ -1,17 +1,18 @@ -import { useId, useMemo } from "react"; +import { useMemo } from "react"; import type { ViewProps } from "@/components/art/kinds"; +import { FlowchartSvg } from "@/components/art/flowchart"; import SourceView from "@/components/art/views/source-view"; import { layoutFlowchart, parseFlowchart } from "@/lib/art/mermaid"; import { cn } from "@/lib/utils"; // A diagram: a Mermaid flowchart drawn by Shipyard in the theme (lib/art/ -// mermaid.ts); any other Mermaid diagram as its source, said so. +// mermaid.ts, components/art/flowchart.tsx); any other Mermaid diagram as +// its source, said so. export default function DiagramView(props: ViewProps) { const { body, size, height } = props; const g = useMemo(() => parseFlowchart(body), [body]); const layout = useMemo(() => (g ? layoutFlowchart(g) : undefined), [g]); - const arrow = `art-arrow-${useId().replace(/:/g, "")}`; if (!g || !layout || !g.nodes.length) return (
@@ -20,53 +21,9 @@ export default function DiagramView(props: ViewProps) {
); const thumb = size === "thumb"; - const { pos, W, H, NW, NH, lr } = layout; - const pad = 14; return (
- n.label).join(", ")}`}> - - - - - - {g.edges.map((e, i) => { - const a = pos[e.from]; - const b = pos[e.to]; - if (!a || !b) return null; - const [x0, y0, x1, y1] = lr ? [a.x + NW, a.y + NH / 2, b.x - 2, b.y + NH / 2] : [a.x + NW / 2, a.y + NH, b.x + NW / 2, b.y - 2]; - const back = lr ? x1 < x0 : y1 < y0; - const d = back ? `M${x0},${y0} C${x0 + 60},${y0 + 70} ${x1 - 60},${y1 + 70} ${x1},${y1}` : lr ? `M${x0},${y0} C${(x0 + x1) / 2},${y0} ${(x0 + x1) / 2},${y1} ${x1},${y1}` : `M${x0},${y0} C${x0},${(y0 + y1) / 2} ${x1},${(y0 + y1) / 2} ${x1},${y1}`; - return ( - - - {e.label && !thumb && ( - - - - {e.label} - - - )} - - ); - })} - {g.nodes.map((n, i) => { - const p = pos[n.id]; - const store = n.shape === "store"; - const tint = store ? "var(--chart-3)" : n.shape === "diamond" ? "var(--chart-4)" : i === 0 ? "var(--chart-1)" : undefined; - const r = n.shape === "round" || n.shape === "circle" ? NH / 2 : n.shape === "diamond" ? 4 : 8; - return ( - - - {store && } - - {n.label.length > 26 ? `${n.label.slice(0, 25)}…` : n.label} - - - ); - })} - +
); } diff --git a/app/src/components/conversation/code-blocks.css b/app/src/components/conversation/code-blocks.css new file mode 100644 index 00000000..8934e2d7 --- /dev/null +++ b/app/src/components/conversation/code-blocks.css @@ -0,0 +1,27 @@ +/* A reply's code block drawn as a diagram or a diff (code-blocks.tsx): the + same frame and head as any code block (conversation.css), the drawing in + place of the
, which stays for Copy and Source. */
+.cv-md .cv-pre[data-drawn] > pre { display: none; }
+.cv-md .cv-pre:not([data-drawn]) > .cv-pre-drawn { display: none; }
+.cv-md .cv-pre-slot { display: contents; }
+
+/* Source / Diagram, beside Copy and like it. */
+.cv-md .cv-pre-toggle { display: inline-flex; align-items: center; gap: 0.3125rem; height: 1.375rem; padding: 0 0.375rem; border-radius: var(--radius-sm, 0.375rem); color: var(--muted-foreground); cursor: pointer; outline: none; transition: color 120ms ease, background-color 120ms ease; }
+.cv-md .cv-pre-toggle svg { width: 0.8125rem; height: 0.8125rem; }
+.cv-md .cv-pre-toggle:hover { color: var(--foreground); background: var(--accent); }
+.cv-md .cv-pre-toggle:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
+/* Mermaid Shipyard doesn't draw (a sequence diagram, say), said quietly. */
+.cv-md .cv-pre-note { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.8; }
+
+/* A flowchart: centred, at its own size where it fits; wider than the
+   column, it scrolls sideways inside the frame. It doesn't fade in as an
+   artifact's does: a chat's rows come back as it scrolls. */
+.cv-md .cv-pre-diagram { overflow-x: auto; padding: 0.5rem 0.75rem; }
+.cv-md .cv-pre-diagram > svg { display: block; margin: 0 auto; }
+.cv-md .cv-pre-diagram .art-node-g, .cv-md .cv-pre-diagram .art-edge { animation: none; }
+
+/* A diff: on the card, as an edit's change is, its files one under another. */
+.cv-md .cv-pre[data-drawn="diff"] { background: var(--card); }
+.cv-md .cv-pre[data-drawn="diff"] > .cv-pre-head { background: color-mix(in srgb, var(--muted) 30%, transparent); }
+.cv-md .cv-pre-file + .cv-pre-file { border-top: 1px solid var(--border); }
+.cv-md .cv-pre-file { font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.25rem; font-variant-ligatures: none; }
diff --git a/app/src/components/conversation/code-blocks.tsx b/app/src/components/conversation/code-blocks.tsx
new file mode 100644
index 00000000..4a0c94ef
--- /dev/null
+++ b/app/src/components/conversation/code-blocks.tsx
@@ -0,0 +1,75 @@
+import { lazy, type RefObject, Suspense, useLayoutEffect, useState } from "react";
+
+import "@/components/conversation/code-blocks.css";
+import { guessLanguage } from "@/components/conversation/code-highlight";
+
+// CodeBlocks draws a reply's mermaid and diff blocks (lib/code-blocks):
+// a flowchart as its diagram, with Source in the frame's head to read what
+// it was drawn from; a diff as the chat's edits are drawn (components/
+// diff). The reply is HTML (markdown.tsx), so each drawing is a portal
+// into its block's frame, beside the 
, which stays: Copy still takes
+// the text, the highlighter still colours it, and Source (or a renderer
+// that can't load) shows it. A draft stays text: a diagram drawn from half
+// its lines would jump about as the rest came.
+//
+// What draws them (drawn-block.tsx) loads with the first such block, not
+// with the app; until then the block is its text.
+const Drawn = lazy(() => import("@/components/conversation/drawn-block"));
+
+export interface Block {
+  key: string;
+  frame: HTMLElement;
+  // Where the drawing goes, after the 
; and a place in the head,
+  // before Copy.
+  host: HTMLElement;
+  slot: HTMLElement;
+  lang: string;
+  text: string;
+}
+
+const DRAWN = new Set(["mermaid", "diff", "patch"]);
+
+let generation = 0;
+
+export function CodeBlocks({ body, html, draft }: { body: RefObject; html: string; draft: boolean }) {
+  const [blocks, setBlocks] = useState([]);
+  // Before paint, so a block already drawable never shows as text first.
+  useLayoutEffect(() => {
+    const root = body.current;
+    const found: Block[] = [];
+    const gen = ++generation;
+    if (root && !draft)
+      root.querySelectorAll(".cv-pre").forEach((frame, i) => {
+        const code = frame.querySelector(":scope > pre > code");
+        const copy = frame.querySelector(":scope > .cv-pre-head > [data-copy-code]");
+        if (!code || !copy) return;
+        const text = code.textContent ?? "";
+        const named = /\blanguage-([\w#+.-]+)/.exec(code.className)?.[1]?.toLowerCase();
+        const lang = named ?? guessLanguage(text);
+        if (!DRAWN.has(lang)) return;
+        const host = document.createElement("div");
+        host.className = "cv-pre-drawn";
+        frame.append(host);
+        const slot = document.createElement("span");
+        slot.className = "cv-pre-slot";
+        copy.before(slot);
+        found.push({ key: `${gen}-${i}`, frame, host, slot, lang, text });
+      });
+    setBlocks((was) => (was.length || found.length ? found : was));
+    return () => {
+      for (const b of found) {
+        b.host.remove();
+        b.slot.remove();
+        delete b.frame.dataset.drawn;
+      }
+    };
+  }, [body, html, draft]);
+  if (!blocks.length) return null;
+  return (
+    
+      {blocks.map((b) => (
+        
+      ))}
+    
+  );
+}
diff --git a/app/src/components/conversation/code-highlight.ts b/app/src/components/conversation/code-highlight.ts
index 5bd9f7c2..975d1b3d 100644
--- a/app/src/components/conversation/code-highlight.ts
+++ b/app/src/components/conversation/code-highlight.ts
@@ -60,7 +60,7 @@ const NAMES: Record = {
   go: "Go", golang: "Go", rs: "Rust", rust: "Rust", py: "Python", python: "Python", rb: "Ruby", ruby: "Ruby", swift: "Swift", kotlin: "Kotlin", java: "Java",
   c: "C", cpp: "C++", "c++": "C++", cs: "C#", csharp: "C#", php: "PHP", lua: "Lua", sql: "SQL", graphql: "GraphQL",
   json: "JSON", jsonc: "JSON", yaml: "YAML", yml: "YAML", toml: "TOML", xml: "XML", html: "HTML", css: "CSS", scss: "SCSS",
-  md: "Markdown", markdown: "Markdown", diff: "Diff", patch: "Diff", dockerfile: "Dockerfile", docker: "Dockerfile", makefile: "Makefile", nix: "Nix",
+  md: "Markdown", markdown: "Markdown", mermaid: "Mermaid", diff: "Diff", patch: "Diff", dockerfile: "Dockerfile", docker: "Dockerfile", makefile: "Makefile", nix: "Nix",
   text: "Text", txt: "Text", plaintext: "Text",
 };
 export function languageName(lang: string): string {
diff --git a/app/src/components/conversation/drawn-block.tsx b/app/src/components/conversation/drawn-block.tsx
new file mode 100644
index 00000000..7350b766
--- /dev/null
+++ b/app/src/components/conversation/drawn-block.tsx
@@ -0,0 +1,127 @@
+import { CodeXmlIcon, WorkflowIcon } from "lucide-react";
+import { type CSSProperties, useLayoutEffect, useMemo, useState } from "react";
+import { createPortal } from "react-dom";
+
+import "@/components/art/art.css";
+import { FlowchartSvg, flowchartBox } from "@/components/art/flowchart";
+import type { Block } from "@/components/conversation/code-blocks";
+import type { FileDiffMetadata } from "@/components/diff/diffs";
+import { useDark, useDiffs } from "@/components/diff/load";
+import { Tip } from "@/components/tip";
+import { useActiveTheme } from "@/hooks/use-theme";
+import { type Flowchart, layoutFlowchart } from "@/lib/art/mermaid";
+import { chartVars } from "@/lib/art/theme";
+import { type DiffShape, drawnBlock, tidyFile } from "@/lib/code-blocks";
+
+// A reply's mermaid or diff block, drawn into its frame (code-blocks.tsx
+// finds them and loads this). What can't be drawn stays its text: the
+// frame shows its 
 until a drawing marks it data-drawn.
+export default function Drawn({ block }: { block: Block }) {
+  const drawn = useMemo(() => drawnBlock(block.lang, block.text), [block]);
+  if (!drawn) return null;
+  if (drawn.kind === "flowchart") return ;
+  if (drawn.kind === "diff") return ;
+  return createPortal(Shown as written: Shipyard draws flowcharts, block.slot);
+}
+
+// ---- Mermaid ----
+
+function FlowchartBlock({ block, chart }: { block: Block; chart: Flowchart }) {
+  const [source, setSource] = useState(false);
+  const theme = useActiveTheme();
+  const vars = useMemo(() => chartVars(theme) as CSSProperties, [theme]);
+  const layout = useMemo(() => layoutFlowchart(chart), [chart]);
+  const { frame } = block;
+  useLayoutEffect(() => {
+    if (source) delete frame.dataset.drawn;
+    else frame.dataset.drawn = "diagram";
+  }, [frame, source]);
+  // Drawn at its own size where the column has the room; narrower, it
+  // shrinks to three quarters of that, then scrolls sideways.
+  const { w, h } = useMemo(() => flowchartBox(chart, layout, true), [chart, layout]);
+  const size: CSSProperties = { width: "100%", maxWidth: w, minWidth: Math.round(w * 0.75), aspectRatio: `${w} / ${h}`, height: "auto" };
+  return (
+    <>
+      {createPortal(
+        
+          
+        ,
+        block.slot,
+      )}
+      {createPortal(
+        
+ +
, + block.host, + )} + + ); +} + +// ---- Diffs ---- + +// The renderer loads with the first diff shown (components/diff/load); +// until then, and if it can't, the block is its highlighted text. +function DiffBlock({ block, shape, patch }: { block: Block; shape: DiffShape; patch: string }) { + const lib = useDiffs(true); + const dark = useDark(); + const mod = lib && "mod" in lib ? lib.mod : undefined; + const files = useMemo(() => { + if (!mod) return undefined; + try { + return mod + .parse(patch) + .map(tidyFile) + .filter((f) => f.hunks.length); + } catch { + return undefined; + } + }, [mod, patch]); + const ready = !!files?.length; + const { frame } = block; + useLayoutEffect(() => { + if (!ready) return; + frame.dataset.drawn = "diff"; + return () => void delete frame.dataset.drawn; + }, [frame, ready]); + if (!mod || !files || !ready) return null; + const style = { "--berth-diff-bg": "var(--card)" } as CSSProperties; + return createPortal( + files.map((f, i) => ( +
+ {shape === "files" && } + +
+ )), + block.host, + ); +} + +// FileHead names a file in a patch, as an edit's header does, with what +// it adds and takes out. +function FileHead({ f }: { f: FileDiffMetadata }) { + const added = f.hunks.reduce((n, h) => n + h.additionLines, 0); + const removed = f.hunks.reduce((n, h) => n + h.deletionLines, 0); + const what = f.type === "new" ? "New" : f.type === "deleted" ? "Deleted" : f.prevName ? "Renamed" : undefined; + return ( +
+ + {what && `${what} `} + {f.prevName && ( + <> + {f.prevName} →{" "} + + )} + {f.name} + + + {added > 0 && +{added}} + {added > 0 && removed > 0 && " "} + {removed > 0 && −{removed}} + +
+ ); +} diff --git a/app/src/components/conversation/markdown.tsx b/app/src/components/conversation/markdown.tsx index beb24b1b..14f8e2e3 100644 --- a/app/src/components/conversation/markdown.tsx +++ b/app/src/components/conversation/markdown.tsx @@ -1,6 +1,7 @@ import { CopyIcon } from "lucide-react"; import { memo, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { CodeBlocks } from "@/components/conversation/code-blocks"; import { InlineImages } from "@/components/conversation/inline-images"; import { guessLanguage, languageName, useCodeHighlight } from "@/components/conversation/code-highlight"; import { useFileLinks } from "@/components/conversation/file-links"; @@ -168,6 +169,8 @@ export const Markdown = memo(function Markdown({ text, copy = true, draft = fals + {/* Mermaid flowcharts and diffs, drawn into their blocks. */} + {copy && (
diff --git a/app/src/components/diff/diffs.tsx b/app/src/components/diff/diffs.tsx index d130d875..3401ce5e 100644 --- a/app/src/components/diff/diffs.tsx +++ b/app/src/components/diff/diffs.tsx @@ -301,6 +301,8 @@ export interface DiffProps { layout: "split" | "unified"; dark: boolean; wrap?: boolean; + // Off for lines that number nothing: a reply's bare +/- lines. + lineNumbers?: boolean; className?: string; style?: CSSProperties; // Notes drawn under their lines, and a button in the gutter to add one. @@ -309,7 +311,7 @@ export interface DiffProps { onAddNote?(side: "old" | "new", line: number): void; } -export function Diff({ fileDiff, layout, dark, wrap = true, className, style, notes, renderNote, onAddNote }: DiffProps) { +export function Diff({ fileDiff, layout, dark, wrap = true, lineNumbers = true, className, style, notes, renderNote, onAddNote }: DiffProps) { const add = useRef(onAddNote); add.current = onAddNote; const commentable = !!onAddNote; @@ -324,13 +326,14 @@ export function Diff({ fileDiff, layout, dark, wrap = true, className, style, no lineDiffType: "word-alt" as const, hunkSeparators: "simple" as const, disableFileHeader: true, + disableLineNumbers: !lineNumbers, unsafeCSS: CHAT_CSS, enableGutterUtility: commentable, onGutterUtilityClick: commentable ? (r: { start: number; side?: "deletions" | "additions" }) => add.current?.(r.side === "deletions" ? "old" : "new", r.start) : undefined, }), - [dark, layout, wrap, commentable, syntax], + [dark, layout, wrap, lineNumbers, commentable, syntax], ); const annotations = useMemo[] | undefined>( () => notes?.map((n) => ({ side: n.side === "old" ? "deletions" : "additions", lineNumber: n.line, metadata: n.key })), diff --git a/app/src/lib/code-blocks.test.ts b/app/src/lib/code-blocks.test.ts new file mode 100644 index 00000000..a51a0ccf --- /dev/null +++ b/app/src/lib/code-blocks.test.ts @@ -0,0 +1,87 @@ +// node --experimental-strip-types --test src/lib/code-blocks.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { diffPatch, drawnBlock, tidyFile } from "./code-blocks.ts"; + +// Which of a reply's code blocks the chat draws (components/conversation/ +// code-blocks.tsx), and the patch a diff block hands the renderer. + +const GIT = ["di", "ff --git"].join(""); + +test("a mermaid flowchart draws; other mermaid, and a flowchart with nothing in it, stay as written", () => { + const flow = drawnBlock("mermaid", "flowchart LR\n A[Request] --> B{Cached?}\n B -->|yes| C[Serve]\n B -->|no| D[(acme-db)]"); + assert.equal(flow?.kind, "flowchart"); + assert.equal(flow?.kind === "flowchart" && flow.chart.nodes.length, 4); + assert.equal(drawnBlock("mermaid", "graph TD;\nA-->B;")?.kind, "flowchart"); + assert.deepEqual(drawnBlock("mermaid", "sequenceDiagram\n Alice->>Bob: hi"), { kind: "mermaid" }); + assert.deepEqual(drawnBlock("mermaid", "pie title Pets\n \"Dogs\" : 3"), { kind: "mermaid" }); + assert.deepEqual(drawnBlock("mermaid", "flowchart TD\n"), { kind: "mermaid" }); +}); + +test("other languages, empty blocks and very long ones aren't drawn", () => { + assert.equal(drawnBlock("typescript", "const a = 1;"), undefined); + assert.equal(drawnBlock("text", "+ a\n- b"), undefined); + assert.equal(drawnBlock("diff", " \n"), undefined); + assert.equal(drawnBlock("diff", Array.from({ length: 2100 }, (_, i) => `+${i}`).join("\n")), undefined); +}); + +test("a patch with its files' headers is files, its hunks recounted", () => { + const text = `${GIT} a/src/acme.ts b/src/acme.ts\nindex 1a2b3c4..5d6e7f8 100644\n--- a/src/acme.ts\n+++ b/src/acme.ts\n@@ -10,9 +10,9 @@ export function load()\n const a = 1;\n-const b = 2;\n+const b = 3;\n const c = 4;\n`; + const d = diffPatch(text); + assert.equal(d?.shape, "files"); + assert.match(d!.patch, /^@@ -10,3 \+10,3 @@ export function load\(\)$/m); + assert.match(d!.patch, /^index 1a2b3c4\.\.5d6e7f8 100644$/m); + // patch is accepted as diff's other name. + assert.equal(drawnBlock("patch", text)?.kind, "diff"); +}); + +test("an empty line inside a hunk is context; between files it isn't", () => { + const text = "--- a/x.go\n+++ b/x.go\n@@ -1,4 +1,4 @@\n func a() {\n\n-\treturn 1\n+\treturn 2\n\n--- a/y.go\n+++ b/y.go\n@@ -1 +1 @@\n-old\n+new\n"; + const d = diffPatch(text)!; + assert.equal(d.shape, "files"); + assert.match(d.patch, /^@@ -1,3 \+1,3 @@$/m); + assert.match(d.patch, /^ func a\(\) \{\n \n-\treturn 1$/m); + assert.match(d.patch, /^\+\treturn 2\n--- a\/y\.go$/m); + assert.match(d.patch, /^@@ -1,1 \+1,1 @@$/m); +}); + +test("in a git patch, a file added without git's first line gets one, new or gone", () => { + const text = `${GIT} a/src/acme.ts b/src/acme.ts\n--- a/src/acme.ts\n+++ b/src/acme.ts\n@@ -1 +1 @@\n-a\n+b\n--- /dev/null\n+++ b/src/pairs.ts\n@@ -0,0 +1 @@\n+c\n--- a/src/old.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-d\n`; + const lines = diffPatch(text)!.patch.split("\n"); + assert.equal(lines.filter((l) => l.startsWith(GIT)).length, 3); + assert.deepEqual(lines.slice(6, 9), [`${GIT} a/src/pairs.ts b/src/pairs.ts`, "new file mode 100644", "--- /dev/null"]); + assert.deepEqual(lines.slice(12, 14), [`${GIT} a/src/old.ts b/src/old.ts`, "deleted file mode 100644"]); +}); + +test("hunks without file headers are numbered but name no file", () => { + const d = diffPatch("@@ -40,2 +40,3 @@\n retries: 3\n-timeout: 10\n+timeout: 30\n+backoff: 2")!; + assert.equal(d.shape, "hunks"); + assert.equal(d.patch, "--- a/change\n+++ b/change\n@@ -40,2 +40,3 @@\n retries: 3\n-timeout: 10\n+timeout: 30\n+backoff: 2\n"); +}); + +test("bare +/- lines are one hunk; an unprefixed line is context as written", () => { + const d = diffPatch("function total() {\n- return a + b;\n+ return a + b + c;\n }")!; + assert.equal(d.shape, "lines"); + assert.equal(d.patch, "--- a/change\n+++ b/change\n@@ -1,3 +1,3 @@\n function total() {\n- return a + b;\n+ return a + b + c;\n }\n"); +}); + +test("a diff block that isn't a diff stays as written", () => { + // No line added or taken out. + assert.equal(diffPatch("just some words\nand more"), undefined); + // Headers with no hunk: a rename, a binary file. + assert.equal(diffPatch(`${GIT} a/logo.png b/logo.png\nBinary files differ`), undefined); + assert.equal(diffPatch("--- a/x\n+++ b/x\n"), undefined); +}); + +test("tidyFile names files as git would", () => { + const f = (name: string, prevName: string | undefined, type: "change" | "rename-pure" | "rename-changed" | "new" | "deleted" = "rename-changed") => ({ name, prevName, type }); + assert.deepEqual(tidyFile(f("b/src/acme.ts", "a/src/acme.ts")), f("src/acme.ts", undefined, "change")); + assert.deepEqual(tidyFile(f("b/new.md", "/dev/null")), f("new.md", undefined, "new")); + assert.deepEqual(tidyFile(f("/dev/null", "a/old.md")), f("old.md", undefined, "deleted")); + assert.deepEqual(tidyFile(f("b/to.ts", "a/from.ts")), f("to.ts", "from.ts")); + // diff -u's times after a tab. + assert.deepEqual(tidyFile(f("acme.c\t2026-01-02 10:00:00", "acme.c\t2026-01-01 10:00:00")), f("acme.c", undefined, "change")); + // Git's own header names them already: a directory called b stays. + assert.deepEqual(tidyFile(f("b/readme.md", undefined, "change")), f("b/readme.md", undefined, "change")); +}); diff --git a/app/src/lib/code-blocks.ts b/app/src/lib/code-blocks.ts new file mode 100644 index 00000000..b9b9244b --- /dev/null +++ b/app/src/lib/code-blocks.ts @@ -0,0 +1,160 @@ +import { type Flowchart, parseFlowchart } from "./art/mermaid.ts"; + +// A code block in a reply that the chat draws instead of showing it as +// text (components/conversation/code-blocks.tsx): a Mermaid flowchart as +// its diagram, a diff as the chat's edits are drawn. What can't be drawn +// stays the code it was. + +export type DiffShape = + // A patch with its files' headers (--- and +++): files, named and + // numbered by their own lines. + | "files" + // Hunks (@@ -a,b +c,d @@) with no file: numbered, unnamed. + | "hunks" + // Lines led by +, - and a space, nothing else: no numbers, as there's + // nothing to count them from. + | "lines"; + +export type DrawnBlock = + | { kind: "flowchart"; chart: Flowchart } + // Mermaid that isn't a flowchart Shipyard can draw (a sequence diagram, + // a gantt): its source, said so. + | { kind: "mermaid" } + // The block as a patch the diff renderer reads. + | { kind: "diff"; shape: DiffShape; patch: string }; + +// Longer blocks stay as they are: drawing them would cost more than it +// gives (code-highlight.ts leaves them plain, too). +const MAX_LINES = 2000; + +// drawnBlock is how a block whose fence names lang (or, unnamed, whose +// language was guessed) draws, if it does. +export function drawnBlock(lang: string, text: string): DrawnBlock | undefined { + if (!text.trim() || text.split("\n").length > MAX_LINES) return undefined; + if (lang === "mermaid") { + const chart = parseFlowchart(text); + return chart?.nodes.length ? { kind: "flowchart", chart } : { kind: "mermaid" }; + } + if (lang === "diff" || lang === "patch") { + const d = diffPatch(text); + return d && { kind: "diff", ...d }; + } + return undefined; +} + +const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/; +const GIT = /^(diff --git |index [0-9a-f]+\.\.|new file mode |deleted file mode |similarity index |rename (from|to) )/; +// The name the renderer gets for a change that doesn't name its file. +const UNNAMED = "change"; + +const fileHead = (lines: string[], i: number) => lines[i].startsWith("--- ") && lines[i + 1]?.startsWith("+++ "); + +// diffPatch reads a diff block as a patch for the renderer, and its shape; +// undefined when it isn't one. Agents write diffs by hand: a hunk's counts +// are worked out again from its lines, and an empty line inside a hunk is +// a context line whose space got trimmed. +export function diffPatch(text: string): { shape: DiffShape; patch: string } | undefined { + const lines = text.replace(/\r\n?/g, "\n").replace(/\n+$/, "").split("\n"); + const files = lines.some((_, i) => fileHead(lines, i)); + if (lines.some((l) => HUNK.test(l))) { + const body = recount(lines); + if (!body) return undefined; + return files ? { shape: "files", patch: body } : { shape: "hunks", patch: `--- a/${UNNAMED}\n+++ b/${UNNAMED}\n${body}` }; + } + // Headers with no hunk (a rename, a binary file) have no lines to draw. + if (files || lines.some((l) => GIT.test(l))) return undefined; + if (!lines.some((l) => /^[+-]/.test(l))) return undefined; + // Bare lines: one hunk. A line led by neither is context, as written. + const body = lines.map((l) => (/^[+\- \\]/.test(l) ? l : ` ${l}`)); + const old = body.filter((l) => l[0] === " " || l[0] === "-").length; + const next = body.filter((l) => l[0] === " " || l[0] === "+").length; + return { shape: "lines", patch: `--- a/${UNNAMED}\n+++ b/${UNNAMED}\n@@ -1,${old} +1,${next} @@\n${body.join("\n")}\n` }; +} + +// recount writes each hunk's header again with the lines it has. Lines +// before the first hunk and between files (headers) pass as they are; in +// a git patch, a file someone added by hand without git's own first line +// gets one, as the renderer starts a file there. +function recount(lines: string[]): string | undefined { + const out: string[] = []; + const git = lines.some((l) => l.startsWith("diff --git ")); + let named = false; + let hunk: { at: number; a: string; c: string; rest: string; old: number; next: number } | undefined; + const close = () => { + if (hunk) out[hunk.at] = `@@ -${hunk.a},${hunk.old} +${hunk.c},${hunk.next} @@${hunk.rest}`; + hunk = undefined; + }; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + const m = HUNK.exec(l); + if (m) { + close(); + hunk = { at: out.length, a: m[1], c: m[2], rest: m[3], old: 0, next: 0 }; + out.push(l); + continue; + } + if (fileHead(lines, i)) { + close(); + if (git && !named) out.push(gitLines(l.slice(4), lines[i + 1].slice(4))); + named = false; + out.push(l); + continue; + } + if (!hunk || GIT.test(l)) { + close(); + if (l.startsWith("diff --git ")) named = true; + out.push(l); + continue; + } + if (l.startsWith("\\")) { + out.push(l); + continue; + } + // Empty lines before the next header (or the end) part hunks; others + // are context. + if (l === "") { + let j = i; + while (j < lines.length && lines[j] === "") j++; + if (j === lines.length || HUNK.test(lines[j]) || GIT.test(lines[j]) || fileHead(lines, j)) { + i = j - 1; + close(); + continue; + } + } + const line = /^[+\- ]/.test(l) ? l : ` ${l}`; + if (line[0] !== "+") hunk.old++; + if (line[0] !== "-") hunk.next++; + out.push(line); + } + close(); + return out.length ? `${out.join("\n")}\n` : undefined; +} + +// gitLines are git's first lines for a file, from its --- and +++ names: +// which file, and whether it's new or gone. +function gitLines(from: string, to: string): string { + const name = (n: string) => n.replace(/\t.*$/, "").replace(/^[ab]\//, ""); + const [a, b] = [name(from), name(to)]; + const head = `diff --git a/${a === "/dev/null" ? b : a} b/${b === "/dev/null" ? a : b}`; + return a === "/dev/null" ? `${head}\nnew file mode 100644` : b === "/dev/null" ? `${head}\ndeleted file mode 100644` : head; +} + +type ChangeType = "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + +// tidyFile names a file as git would: a patch without git's own header +// (diff -u, or written by hand) leaves the a/ and b/ on its names and +// reads every file as renamed, or one side as /dev/null. +export function tidyFile(f: T): T { + // diff -u puts the file's time after a tab. + let name = f.name.replace(/\t.*$/, ""); + if (f.prevName === undefined) return { ...f, name }; + let prev = f.prevName.replace(/\t.*$/, ""); + if ((prev === "/dev/null" || prev.startsWith("a/")) && (name === "/dev/null" || name.startsWith("b/"))) { + prev = prev.replace(/^a\//, ""); + name = name.replace(/^b\//, ""); + } + if (prev === "/dev/null") return { ...f, name, prevName: undefined, type: "new" }; + if (name === "/dev/null") return { ...f, name: prev, prevName: undefined, type: "deleted" }; + if (prev === name) return { ...f, name, prevName: undefined, type: "change" }; + return { ...f, name, prevName: prev }; +} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 7f3d4423..32143456 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -22,6 +22,16 @@ again. structured, with your *Permissions* choice, instead of always in their terminals. Update your boxes (`berth upgrade BOX`, or Update all) for it to take effect. +- **Mermaid flowcharts in a reply draw as diagrams.** A `mermaid` code + block with a flowchart shows as the diagram, in the theme's colours, the + way a diagram artifact does; *Source* in its corner shows what it was + drawn from, and *Copy* still copies that. Other Mermaid diagrams, such + as sequence diagrams, show as written, and a reply still being written + shows its blocks as text until it's done. +- **Diffs in a reply look like the chat's edits.** A `diff` or `patch` + code block draws with file names, line numbers and highlighting, as an + agent's edits do. Plain lines of `+` and `-` show coloured, without + numbers. A block that isn't really a diff stays as written. ## v0.3.20 From 15e03713e9abd0b884680d47b09649f8675d599f Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 14 Oct 2026 17:33:08 +0100 Subject: [PATCH 2/2] fix(app): mermaid flowcharts with subgraphs, shapes and line breaks draw as written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flowchart reader now takes the syntax agents write: every common node shape (and A@{ shape: … }), labels without their quotes,
as a line break, #quot; and & codes decoded; every edge form with its label, chains and a & b fan-outs; and subgraphs, nested, as titled tinted boxes. Layout lays each subgraph out inside and places it as one block, honours its direction as Mermaid does, and leaves a cycle's closing edge out of layering so loops curve round the side. Charts without subgraphs are laid out exactly as before. Co-Authored-By: Claude Opus 5.5 --- app/e2e/code-blocks.spec.ts | 75 ++ app/package.json | 2 +- app/src/components/art/art.css | 6 +- app/src/components/art/flowchart.tsx | 174 ++++- .../components/conversation/code-blocks.css | 2 +- app/src/lib/art/mermaid.test.ts | 389 +++++++++++ app/src/lib/art/mermaid.ts | 645 +++++++++++++++--- docs/changelog.mdx | 8 + 8 files changed, 1186 insertions(+), 115 deletions(-) create mode 100644 app/src/lib/art/mermaid.test.ts diff --git a/app/e2e/code-blocks.spec.ts b/app/e2e/code-blocks.spec.ts index 004237b2..11b04bad 100644 --- a/app/e2e/code-blocks.spec.ts +++ b/app/e2e/code-blocks.spec.ts @@ -33,6 +33,47 @@ const DIAGRAM_REPLY = [ FENCE, ].join("\n"); +// A reply an agent wrote, as written: shapes, quoted labels,
s, +// labelled edges and nested subgraphs with their own directions. +const PHYSICS = `flowchart TD + Frame(["Render frame"]) --> Acc["Accumulator
A += frame time"] + Acc --> Check{"A ≥ Δt?"} + Check -- "no" --> Interp["Interpolate for rendering
α = A / Δt"] + Interp --> Frame + + Check -- "yes" --> Step + + subgraph Step["Physics step (fixed Δt)"] + direction TB + IV["1 · Integrate velocities
v += Δt·(F/m + g)
ω += Δt·I⁻¹(τ - ω×Iω)"] + + subgraph Detect["2 · Collision detection"] + direction LR + BP["Broadphase
AABB tree / sweep-and-prune
O(n log n)"] --> Pairs[/"Candidate pairs"/] + Pairs --> NP{"Shape type"} + NP -- "spheres" --> SS["Sphere test"] + NP -- "boxes" --> SAT["SAT
15 axes"] + NP -- "convex" --> GJK["GJK"] --> EPA["EPA
depth + normal"] + end + + Manifold[/"Contacts
point, normal n, depth d"/] + + subgraph Solve["3 · Constraint solver (PGS)"] + direction TB + Warm["Warm start"] --> Iter["Iterate constraints"] + Iter --> Clamp["Clamp accumulated impulse"] + Clamp --> Iter + end + + IV --> Detect + Detect --> Manifold + Manifold --> Solve + Solve --> Pos["Integrate positions"] + end + + Step --> Acc`; +const PHYSICS_REPLY = ["## One physics step", "", `${FENCE}mermaid`, PHYSICS, FENCE].join("\n"); + const DIFF_REPLY = [ "## The fix", "", @@ -91,6 +132,40 @@ test("a mermaid flowchart in a reply draws as its diagram, with its source a cli await expect(sequence.locator(".cv-pre-note")).toHaveText("Shown as written: Shipyard draws flowcharts"); }); +test("a flowchart with subgraphs, shapes and line breaks draws as it was written", async ({ app }) => { + mockOnly("lands a reply on the demo's agent"); + await app.open({ params: { view: "conversation" } }); + await app.openWorktree("gpu/judge-v2"); + await draftHook(app.page, "land", PHYSICS_REPLY); + const md = app.chat.locator("[data-kind=text] .cv-md").filter({ hasText: "One physics step" }); + const flow = md.locator(".cv-pre").first(); + const svg = flow.locator("svg[role=img]"); + + await expect(svg).toBeVisible(); + await expect(flow.locator(":scope > pre")).toBeHidden(); + await expect(svg.locator("g.art-node-g")).toHaveCount(17); + // Each subgraph is a titled box, Detect and Solve inside Step. + const groups = svg.locator("g.art-group-g"); + await expect(groups).toHaveCount(3); + await expect(groups.locator("text")).toHaveText(["Physics step (fixed Δt)", "2 · Collision detection", "3 · Constraint solver (PGS)"]); + const box = async (id: string) => (await svg.locator(`g[data-group="${id}"] rect`).boundingBox())!; + const [step, detect, solve] = [await box("Step"), await box("Detect"), await box("Solve")]; + for (const inner of [detect, solve]) { + expect(inner.x).toBeGreaterThan(step.x); + expect(inner.y + inner.height).toBeLessThan(step.y + step.height); + } + // Labels without their brackets and quotes, a
a line of its own. + await expect(svg).toHaveAttribute("aria-label", /^Diagram: Render frame, Accumulator A \+= frame time, A ≥ Δt\?, /); + const iv = svg.locator("g.art-node-g", { hasText: "Integrate velocities" }); + await expect(iv.locator("tspan")).toHaveText(["1 · Integrate velocities", "v += Δt·(F/m + g)", "ω += Δt·I⁻¹(τ - ω×Iω)"]); + await expect(svg.locator("g.art-node-g[data-shape=lean-r]")).toHaveCount(2); + await expect(svg.locator("g.art-node-g[data-shape=stadium]")).toHaveText("Render frame"); + await expect(svg.locator(".art-edge text")).toHaveText(["no", "yes", "spheres", "boxes", "convex"]); + await expect(svg).not.toContainText(" { mockOnly("lands a reply on the demo's agent"); await app.open({ params: { view: "conversation" } }); diff --git a/app/package.json b/app/package.json index 617d7c42..f714db37 100644 --- a/app/package.json +++ b/app/package.json @@ -23,7 +23,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts src/lib/code-blocks.test.ts" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts src/lib/code-blocks.test.ts src/lib/art/mermaid.test.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/components/art/art.css b/app/src/components/art/art.css index 8c374208..3977928c 100644 --- a/app/src/components/art/art.css +++ b/app/src/components/art/art.css @@ -4,8 +4,10 @@ .art-scope .art-tick { font-size: 10.5px; fill: var(--chart-label); color: var(--chart-label); font-variant-numeric: tabular-nums; } .art-scope .art-node { font-size: 11.5px; fill: var(--chart-foreground); } +/* A diagram's subgraph title, above its nodes in its box. */ +.art-scope .art-group { font-size: 11px; font-weight: 600; fill: var(--chart-foreground-muted); } .art-scope .art-heat td { height: 26px; min-width: 30px; font-size: 10.5px; font-weight: 550; } -.art-scope .art-cell, .art-scope .art-node-g, .art-scope .art-edge { animation: art-fade 0.9s cubic-bezier(0.85, 0, 0.15, 1) both; } +.art-scope .art-cell, .art-scope .art-node-g, .art-scope .art-group-g, .art-scope .art-edge { animation: art-fade 0.9s cubic-bezier(0.85, 0, 0.15, 1) both; } @keyframes art-fade { from { opacity: 0; } } /* The live pulse: a version just landed. */ @@ -19,5 +21,5 @@ @keyframes art-dot { 50% { opacity: 0.35; } } @media (prefers-reduced-motion: reduce) { - .art-scope .art-cell, .art-scope .art-node-g, .art-scope .art-edge, .art-pulse, .art-dot { animation: none; } + .art-scope .art-cell, .art-scope .art-node-g, .art-scope .art-group-g, .art-scope .art-edge, .art-pulse, .art-dot { animation: none; } } diff --git a/app/src/components/art/flowchart.tsx b/app/src/components/art/flowchart.tsx index 4d7ddfba..12f78d40 100644 --- a/app/src/components/art/flowchart.tsx +++ b/app/src/components/art/flowchart.tsx @@ -1,31 +1,55 @@ import { type CSSProperties, useId } from "react"; -import type { Flowchart, Layout } from "@/lib/art/mermaid"; +import { CAP, type Flowchart, GPAD, type Layout, LINE, lines, type MNode, NH, type Rect } from "@/lib/art/mermaid"; const PAD = 14; -// edgePath is an edge's curve, and its ends: an edge back up the flow -// loops out below the nodes it joins. -function edgePath(layout: Layout, a: { x: number; y: number }, b: { x: number; y: number }) { - const { NW, NH, lr } = layout; - const [x0, y0, x1, y1] = lr ? [a.x + NW, a.y + NH / 2, b.x - 2, b.y + NH / 2] : [a.x + NW / 2, a.y + NH, b.x + NW / 2, b.y - 2]; +// edgePath is an edge's curve, and its ends. Along the flow it leaves a +// node's far side for the next one's near side; an edge back up the flow +// loops out beside the nodes it joins (below them, across), on the side +// it comes from, so it doesn't cut through the ones between. +function edgePath(a: Rect, b: Rect, lr: boolean) { + let [x0, y0, x1, y1] = lr ? [a.x + a.w, a.y + a.h / 2, b.x - 2, b.y + b.h / 2] : [a.x + a.w / 2, a.y + a.h, b.x + b.w / 2, b.y - 2]; const back = lr ? x1 < x0 : y1 < y0; - const c = back ? [x0 + 60, y0 + 70, x1 - 60, y1 + 70] : lr ? [(x0 + x1) / 2, y0, (x0 + x1) / 2, y1] : [x0, (y0 + y1) / 2, x1, (y0 + y1) / 2]; - return { x0, y0, x1, y1, back, c, d: `M${x0},${y0} C${c[0]},${c[1]} ${c[2]},${c[3]} ${x1},${y1}` }; + let c = lr ? [(x0 + x1) / 2, y0, (x0 + x1) / 2, y1] : [x0, (y0 + y1) / 2, x1, (y0 + y1) / 2]; + if (back) { + const self = a === b; + if (lr) { + const below = a.y + a.h / 2 >= b.y + b.h / 2; + [x0, x1] = self ? [a.x + a.w * 0.35, a.x + a.w * 0.65] : [a.x + a.w / 2, b.x + b.w / 2]; + [y0, y1] = below ? [a.y + a.h, b.y + b.h + 2] : [a.y, b.y - 2]; + const out = below ? Math.max(y0, y1) + 40 : Math.min(y0, y1) - 40; + c = [x0, out, x1, out]; + } else { + const right = a.x + a.w / 2 >= b.x + b.w / 2; + [x0, x1] = right ? [a.x + a.w, b.x + b.w + 2] : [a.x, b.x - 2]; + [y0, y1] = self ? [a.y + a.h * 0.35, a.y + a.h * 0.65] : [a.y + a.h / 2, b.y + b.h / 2]; + const out = right ? Math.max(x0, x1) + 48 : Math.min(x0, x1) - 48; + c = [out, y0, out, y1]; + } + } + // Its middle, for a label: the curve at t = ½. + const mx = (x0 + 3 * c[0] + 3 * c[2] + x1) / 8; + const my = (y0 + 3 * c[1] + 3 * c[3] + y1) / 8; + return { x0, y0, x1, y1, back, c, mx, my, d: `M${x0},${y0} C${c[0]},${c[1]} ${c[2]},${c[3]} ${x1},${y1}` }; } +const ends = (g: Flowchart, layout: Layout, i: number) => { + const e = g.edges[i]; + const a = layout.box[e.from]; + const b = layout.box[e.to]; + return a && b ? edgePath(a, b, layout.edgeLr[i] ?? layout.lr) : undefined; +}; + // flowchartBox is the drawing's viewBox: the nodes, with room around // them, and with fit, the loops of edges back up the flow too, which an // artifact's diagram leaves to its frame to cut. export function flowchartBox(g: Flowchart, layout: Layout, fit = false): { x: number; y: number; w: number; h: number } { let [x0, y0, x1, y1] = [0, 0, layout.W, layout.H]; if (fit) - for (const e of g.edges) { - const a = layout.pos[e.from]; - const b = layout.pos[e.to]; - if (!a || !b) continue; - const p = edgePath(layout, a, b); - if (!p.back) continue; + g.edges.forEach((_, i) => { + const p = ends(g, layout, i); + if (!p?.back) return; // The curve's extent, sampled: near enough for a margin. for (let t = 0; t <= 1; t += 0.05) { const u = 1 - t; @@ -33,35 +57,106 @@ export function flowchartBox(g: Flowchart, layout: Layout, fit = false): { x: nu const y = u * u * u * p.y0 + 3 * u * u * t * p.c[1] + 3 * u * t * t * p.c[3] + t * t * t * p.y1; [x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)]; } - } + }); return { x: x0 - PAD, y: y0 - PAD, w: x1 - x0 + PAD * 2, h: y1 - y0 + PAD * 2 }; } +const clip = (s: string, n: number) => ([...s].length > n ? `${[...s].slice(0, n - 1).join("")}…` : s); + +// outline is a node's shape in its w × h box: a rect for most, a path for +// the slanted and pointed ones, ellipses for circles. +function Outline({ n, w, h, fill, stroke, width }: { n: MNode; w: number; h: number; fill: string; stroke: string; width: number }) { + const paint = { fill, stroke, strokeWidth: width }; + const k = Math.min(12, w / 5); + const poly = (pts: number[][]) => p.join(",")).join(" L")} Z`} strokeLinejoin="round" {...paint} />; + switch (n.shape) { + case "circle": + return ; + case "double-circle": + return ( + <> + + + + ); + case "hexagon": + return poly([[k, 0], [w - k, 0], [w, h / 2], [w - k, h], [k, h], [0, h / 2]]); + case "lean-r": + return poly([[k, 0], [w, 0], [w - k, h], [0, h]]); + case "lean-l": + return poly([[0, 0], [w - k, 0], [w, h], [k, h]]); + case "trapezoid": + return poly([[k, 0], [w - k, 0], [w, h], [0, h]]); + case "trapezoid-alt": + return poly([[0, 0], [w, 0], [w - k, h], [k, h]]); + case "asym": + return poly([[0, 0], [w, 0], [w, h], [0, h], [k, h / 2]]); + case "subroutine": + return ( + <> + + + + ); + default: { + // round and stadium are pills, a diamond is a square-cornered box + // (a rhombus would need twice the width for its words). + const r = n.shape === "round" ? Math.min(h, NH) / 2 : n.shape === "stadium" ? h / 2 : n.shape === "diamond" ? 4 : 8; + return ; + } + } +} + // FlowchartSvg draws a Mermaid flowchart laid out by lib/art/mermaid.ts in // the theme's chart colours (--chart-*, set by an .art-scope around it): -// an artifact's diagram, and a mermaid block in a reply. A thumb is a -// card's small picture: bolder lines, no edge labels. fit: flowchartBox. +// an artifact's diagram, and a mermaid block in a reply. Subgraphs are +// tinted boxes behind, titled at their top left; nested ones darken as +// they nest. A thumb is a card's small picture: bolder lines, no edge +// labels. fit: flowchartBox. export function FlowchartSvg({ g, layout, thumb = false, fit = false, className, style }: { g: Flowchart; layout: Layout; thumb?: boolean; fit?: boolean; className?: string; style?: CSSProperties }) { - const arrow = `art-arrow-${useId().replace(/:/g, "")}`; - const { pos, NW, NH } = layout; - const box = flowchartBox(g, layout, fit); + const uid = useId().replace(/:/g, ""); + const [arrow, circle, cross] = ["arrow", "circle", "cross"].map((k) => `art-${k}-${uid}`); + const { box } = layout; + const view = flowchartBox(g, layout, fit); + const line = "var(--chart-foreground-muted)"; + const marker = (e: Flowchart["edges"][number]) => (e.arrow ? arrow : e.tip === "circle" ? circle : e.tip === "cross" ? cross : undefined); return ( - n.label).join(", ")}`}> + n.label.replace(/\n/g, " ")).join(", ")}`}> - + + + + + + + + {g.groups.map((gr) => { + const b = box[gr.id]; + if (!b) return null; + const tint = `color-mix(in oklab, var(--chart-2) ${thumb ? 9 : 6}%, transparent)`; + return ( + + + {!thumb && gr.label && ( + + {clip(gr.label.replace(/\n/g, " "), Math.max(4, Math.floor((b.w - GPAD * 2) / 6.6)))} + + )} + + ); + })} {g.edges.map((e, i) => { - const a = pos[e.from]; - const b = pos[e.to]; - if (!a || !b) return null; - const { x0, y0, x1, y1, d } = edgePath(layout, a, b); + const p = e.hidden ? undefined : ends(g, layout, i); + if (!p) return null; + const m = marker(e); return ( - + {e.label && !thumb && ( - + {e.label} @@ -72,16 +167,25 @@ export function FlowchartSvg({ g, layout, thumb = false, fit = false, className, ); })} {g.nodes.map((n, i) => { - const p = pos[n.id]; + const b = box[n.id]; + if (!b) return null; const store = n.shape === "store"; const tint = store ? "var(--chart-3)" : n.shape === "diamond" ? "var(--chart-4)" : i === 0 ? "var(--chart-1)" : undefined; - const r = n.shape === "round" || n.shape === "circle" ? NH / 2 : n.shape === "diamond" ? 4 : 8; + const stroke = tint ? `color-mix(in oklab, ${tint} 60%, var(--border))` : "var(--border)"; + const rows = lines(n.label).map((l) => clip(l, CAP + 2)); + const top = b.h / 2 + (store ? 3 : 0) - ((rows.length - 1) * LINE) / 2; return ( - - - {store && } - - {n.label.length > 26 ? `${n.label.slice(0, 25)}…` : n.label} + + + {store && } + + {rows.length === 1 + ? rows[0] + : rows.map((r, k) => ( + + {r} + + ))} ); diff --git a/app/src/components/conversation/code-blocks.css b/app/src/components/conversation/code-blocks.css index 8934e2d7..e86aa595 100644 --- a/app/src/components/conversation/code-blocks.css +++ b/app/src/components/conversation/code-blocks.css @@ -18,7 +18,7 @@ artifact's does: a chat's rows come back as it scrolls. */ .cv-md .cv-pre-diagram { overflow-x: auto; padding: 0.5rem 0.75rem; } .cv-md .cv-pre-diagram > svg { display: block; margin: 0 auto; } -.cv-md .cv-pre-diagram .art-node-g, .cv-md .cv-pre-diagram .art-edge { animation: none; } +.cv-md .cv-pre-diagram .art-node-g, .cv-md .cv-pre-diagram .art-group-g, .cv-md .cv-pre-diagram .art-edge { animation: none; } /* A diff: on the card, as an edit's change is, its files one under another. */ .cv-md .cv-pre[data-drawn="diff"] { background: var(--card); } diff --git a/app/src/lib/art/mermaid.test.ts b/app/src/lib/art/mermaid.test.ts new file mode 100644 index 00000000..3a0495c6 --- /dev/null +++ b/app/src/lib/art/mermaid.test.ts @@ -0,0 +1,389 @@ +// node --experimental-strip-types --test src/lib/art/mermaid.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { type Flowchart, labelText, layoutFlowchart, parseFlowchart, type Rect } from "./mermaid.ts"; + +// The Mermaid flowcharts Shipyard draws itself (components/art/flowchart.tsx): +// what it reads from the syntax agents write, and where it puts things. + +const chart = (src: string): Flowchart => { + const g = parseFlowchart(src); + assert.ok(g, "parses"); + return g; +}; +const node = (g: Flowchart, id: string) => g.nodes.find((n) => n.id === id); +const edge = (g: Flowchart, from: string, to: string) => g.edges.find((e) => e.from === from && e.to === to); +const pairs = (g: Flowchart) => g.edges.map((e) => `${e.from}>${e.to}`); + +// A reply an agent wrote, as written. +const PHYSICS = `flowchart TD + Frame(["Render frame"]) --> Acc["Accumulator
A += frame time"] + Acc --> Check{"A ≥ Δt?"} + Check -- "no" --> Interp["Interpolate for rendering
α = A / Δt"] + Interp --> Frame + + Check -- "yes" --> Step + + subgraph Step["Physics step (fixed Δt)"] + direction TB + IV["1 · Integrate velocities
v += Δt·(F/m + g)
ω += Δt·I⁻¹(τ - ω×Iω)"] + + subgraph Detect["2 · Collision detection"] + direction LR + BP["Broadphase
AABB tree / sweep-and-prune
O(n log n)"] --> Pairs[/"Candidate pairs"/] + Pairs --> NP{"Shape type"} + NP -- "spheres" --> SS["Sphere test"] + NP -- "boxes" --> SAT["SAT
15 axes"] + NP -- "convex" --> GJK["GJK"] --> EPA["EPA
depth + normal"] + end + + Manifold[/"Contacts
point, normal n, depth d"/] + + subgraph Solve["3 · Constraint solver (PGS)"] + direction TB + Warm["Warm start"] --> Iter["Iterate constraints"] + Iter --> Clamp["Clamp accumulated impulse"] + Clamp --> Iter + end + + IV --> Detect + Detect --> Manifold + Manifold --> Solve + Solve --> Pos["Integrate positions"] + end + + Step --> Acc`; + +test("every common node shape gives its label without its brackets or quotes", () => { + const g = chart(`flowchart LR + a[Box] --> b(Round) + c([Stadium]) --> d[[Subroutine]] + e[(Database)] --> f((Circle)) + g(((Double))) --> h>Flag] + i{Rhombus} --> j{{Hexagon}} + k[/Lean right/] --> l[\\Lean left\\] + m[/Trapezoid\\] --> n[\\Upside down/] + o["Quoted [with] brackets"] --> p("Quoted (round)") + q@{ shape: cyl, label: "Newer syntax" } --> r@{ shape: notch-rect } + s:::hot --> t[Styled]:::cold`); + const want: [string, string, string][] = [ + ["a", "Box", "box"], + ["b", "Round", "round"], + ["c", "Stadium", "stadium"], + ["d", "Subroutine", "subroutine"], + ["e", "Database", "store"], + ["f", "Circle", "circle"], + ["g", "Double", "double-circle"], + ["h", "Flag", "asym"], + ["i", "Rhombus", "diamond"], + ["j", "Hexagon", "hexagon"], + ["k", "Lean right", "lean-r"], + ["l", "Lean left", "lean-l"], + ["m", "Trapezoid", "trapezoid"], + ["n", "Upside down", "trapezoid-alt"], + ["o", "Quoted [with] brackets", "box"], + ["p", "Quoted (round)", "round"], + ["q", "Newer syntax", "store"], + ["r", "r", "box"], + ["s", "s", "box"], + ["t", "Styled", "box"], + ]; + for (const [id, label, shape] of want) assert.deepEqual([node(g, id)?.label, node(g, id)?.shape], [label, shape], id); + assert.equal(g.edges.length, 10); +}); + +test("labels lose their quotes;
breaks a line; entity codes and markdown strings read as text", () => { + assert.equal(labelText('"Render frame"'), "Render frame"); + assert.equal(labelText("Accumulator
A += frame time"), "Accumulator\nA += frame time"); + assert.equal(labelText("a
b
c
"), "a\nb\nc"); + assert.equal(labelText("Say #quot;hi#quot; #9829; #amp;"), 'Say "hi" ♥ &'); + assert.equal(labelText("x < y && y > z, it's → →"), "x < y && y > z, it's → →"); + assert.equal(labelText('"`**Bold** and _calm_ text`"'), "Bold and calm text"); + assert.equal(labelText("Bold tag"), "Bold tag"); + // Anything else stays as the text it is: it's drawn as SVG text, never HTML. + assert.equal(labelText(''), ''); + assert.equal(labelText("<br> is how"), "
is how"); + const g = chart(`graph TD + A["#quot;Quoted#quot;
twice"] -->|""so""| B["Line one
line two"]`); + assert.equal(node(g, "A")?.label, '"Quoted"\ntwice'); + assert.equal(node(g, "B")?.label, "Line one\nline two"); + assert.equal(edge(g, "A", "B")?.label, '"so"'); +}); + +test("every edge form, with its label in the middle or in pipes, quoted or not", () => { + const g = chart(`flowchart LR + a --> b + a --- c + a -.-> d + a ==> e + a --o f + a --x g + a <--> h + a ~~~ i + a -- text --> j + a -- "quoted text" --> k + a -->|piped| l + a -->|"piped quoted"| m + a -. dotted text .-> n + a == thick text ==> o + a -.- p + a === q + a ---> r + a -- open text --- s + a -- "has --> in it" --> t`); + const e = (to: string) => { + const x = edge(g, "a", to); + assert.ok(x, to); + return x; + }; + assert.deepEqual(e("b"), { from: "a", to: "b", label: undefined, dotted: false, thick: false, arrow: true }); + assert.equal(e("c").arrow, false); + assert.deepEqual([e("d").dotted, e("d").arrow], [true, true]); + assert.deepEqual([e("e").thick, e("e").arrow], [true, true]); + assert.deepEqual([e("f").arrow, e("f").tip], [false, "circle"]); + assert.deepEqual([e("g").arrow, e("g").tip], [false, "cross"]); + assert.deepEqual([e("h").arrow, e("h").both], [true, true]); + assert.equal(e("i").hidden, true); + assert.equal(e("j").label, "text"); + assert.equal(e("k").label, "quoted text"); + assert.equal(e("l").label, "piped"); + assert.equal(e("m").label, "piped quoted"); + assert.deepEqual([e("n").label, e("n").dotted, e("n").arrow], ["dotted text", true, true]); + assert.deepEqual([e("o").label, e("o").thick, e("o").arrow], ["thick text", true, true]); + assert.deepEqual([e("p").dotted, e("p").arrow], [true, false]); + assert.deepEqual([e("q").thick, e("q").arrow], [true, false]); + assert.equal(e("r").arrow, true); + assert.deepEqual([e("s").label, e("s").arrow], ["open text", false]); + assert.equal(e("t").label, "has --> in it"); + assert.equal(g.edges.length, 19); + // Without spaces, and with hyphens in ids. + const t = chart("graph TD\nA-->B\nB-.->C\nmy-node-->|x|other-node\nC==>D"); + assert.deepEqual(pairs(t), ["A>B", "B>C", "my-node>other-node", "C>D"]); + assert.equal(edge(t, "my-node", "other-node")?.label, "x"); +}); + +test("chains and & read edge by edge, every left to every right", () => { + const g = chart(`flowchart LR + a[One] --> b[Two] -- next --> c((Three)) --> d + e & f --> g + g --> h & i + j & k -->|both| l & m`); + assert.deepEqual(pairs(g), ["a>b", "b>c", "c>d", "e>g", "f>g", "g>h", "g>i", "j>l", "j>m", "k>l", "k>m"]); + assert.equal(edge(g, "b", "c")?.label, "next"); + assert.equal(node(g, "c")?.label, "Three"); + assert.equal(edge(g, "k", "m")?.label, "both"); +}); + +test("subgraphs, nested, in all their header forms; an edge to one names the group", () => { + const g = chart(`flowchart LR + start --> backend + subgraph backend["The backend"] + direction TB + api[API] --> db[(DB)] + subgraph jobs [Jobs] + worker --> queue + end + end + subgraph Plain title + x + end + subgraph ops + y + end + api --> jobs + jobs --> ops`); + assert.deepEqual(g.groups, [ + { id: "backend", label: "The backend", dir: "TD" }, + { id: "jobs", label: "Jobs", parent: "backend" }, + { id: "Plain title", label: "Plain title" }, + { id: "ops", label: "ops" }, + ]); + assert.deepEqual( + g.nodes.map((n) => [n.id, n.group]), + [["start", undefined], ["api", "backend"], ["db", "backend"], ["worker", "jobs"], ["queue", "jobs"], ["x", "Plain title"], ["y", "ops"]], + ); + // jobs and ops are groups, not nodes, at both ends of their edges. + assert.ok(edge(g, "api", "jobs") && edge(g, "jobs", "ops")); + assert.equal(node(g, "jobs"), undefined); + + const l = layoutFlowchart(g); + const inside = (a: Rect, b: Rect) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h; + for (const id of ["api", "db", "jobs"]) assert.ok(inside(l.box[id], l.box.backend), `${id} in backend`); + for (const id of ["worker", "queue"]) assert.ok(inside(l.box[id], l.box.jobs), `${id} in jobs`); + assert.ok(!inside(l.box.start, l.box.backend)); + // backend runs top down inside a chart that runs across, and jobs, inside it, with it. + assert.ok(l.box.db.y > l.box.api.y); + assert.ok(l.box.queue.y > l.box.worker.y); + assert.equal(l.edgeLr[g.edges.findIndex((e) => e.from === "api" && e.to === "db")], false); + assert.equal(l.edgeLr[g.edges.findIndex((e) => e.from === "start")], true); + // As in Mermaid, a subgraph a node links straight out of keeps the chart's flow. + const out = chart("flowchart LR\n start --> api\n subgraph backend\n direction TB\n api --> db\n end"); + const lo = layoutFlowchart(out); + assert.ok(lo.box.db.x > lo.box.api.x); +}); + +test("a node first named outside a subgraph moves into the one that names it", () => { + const g = chart("graph TD\n a --> b\n subgraph S\n b --> c\n end\n subgraph T\n c --> d\n end"); + assert.deepEqual( + g.nodes.map((n) => [n.id, n.group]), + [["a", undefined], ["b", "S"], ["c", "S"], ["d", "T"]], + ); +}); + +test("the agent's physics reply reads as its nodes, edges and subgraphs", () => { + const g = chart(PHYSICS); + assert.equal(g.dir, "TD"); + assert.deepEqual( + g.nodes.map((n) => [n.id, n.label, n.shape, n.group]), + [ + ["Frame", "Render frame", "stadium", undefined], + ["Acc", "Accumulator\nA += frame time", "box", undefined], + ["Check", "A ≥ Δt?", "diamond", undefined], + ["Interp", "Interpolate for rendering\nα = A / Δt", "box", undefined], + ["IV", "1 · Integrate velocities\nv += Δt·(F/m + g)\nω += Δt·I⁻¹(τ - ω×Iω)", "box", "Step"], + ["BP", "Broadphase\nAABB tree / sweep-and-prune\nO(n log n)", "box", "Detect"], + ["Pairs", "Candidate pairs", "lean-r", "Detect"], + ["NP", "Shape type", "diamond", "Detect"], + ["SS", "Sphere test", "box", "Detect"], + ["SAT", "SAT\n15 axes", "box", "Detect"], + ["GJK", "GJK", "box", "Detect"], + ["EPA", "EPA\ndepth + normal", "box", "Detect"], + ["Manifold", "Contacts\npoint, normal n, depth d", "lean-r", "Step"], + ["Warm", "Warm start", "box", "Solve"], + ["Iter", "Iterate constraints", "box", "Solve"], + ["Clamp", "Clamp accumulated impulse", "box", "Solve"], + ["Pos", "Integrate positions", "box", "Step"], + ], + ); + assert.deepEqual(g.groups, [ + { id: "Step", label: "Physics step (fixed Δt)", dir: "TD" }, + { id: "Detect", label: "2 · Collision detection", parent: "Step", dir: "LR" }, + { id: "Solve", label: "3 · Constraint solver (PGS)", parent: "Step", dir: "TD" }, + ]); + assert.deepEqual(pairs(g), [ + "Frame>Acc", + "Acc>Check", + "Check>Interp", + "Interp>Frame", + "Check>Step", + "BP>Pairs", + "Pairs>NP", + "NP>SS", + "NP>SAT", + "NP>GJK", + "GJK>EPA", + "Warm>Iter", + "Iter>Clamp", + "Clamp>Iter", + "IV>Detect", + "Detect>Manifold", + "Manifold>Solve", + "Solve>Pos", + "Step>Acc", + ]); + assert.deepEqual( + g.edges.filter((e) => e.label).map((e) => [e.to, e.label]), + [["Interp", "no"], ["Step", "yes"], ["SS", "spheres"], ["SAT", "boxes"], ["GJK", "convex"]], + ); + + const l = layoutFlowchart(g); + const { box } = l; + const inside = (a: Rect, b: Rect) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h; + const apart = (a: Rect, b: Rect) => a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y; + for (const n of g.nodes) if (n.group) assert.ok(inside(box[n.id], box[n.group]), `${n.id} in ${n.group}`); + assert.ok(inside(box.Detect, box.Step) && inside(box.Solve, box.Step)); + // Nothing overlaps what's beside it. + const level = (p: string | undefined) => [...g.nodes.filter((n) => n.group === p).map((n) => n.id), ...g.groups.filter((x) => x.parent === p).map((x) => x.id)]; + for (const p of [undefined, "Step", "Detect", "Solve"]) { + const ids = level(p); + for (const a of ids) for (const b of ids) if (a < b) assert.ok(apart(box[a], box[b]), `${a} and ${b} apart`); + } + // The loop back to Frame doesn't push the flow down: Frame leads. + assert.ok(box.Frame.y < box.Acc.y && box.Acc.y < box.Check.y && box.Check.y < box.Step.y); + // Detect runs across, the rest down; a node with three lines is taller. + assert.ok(box.Pairs.x > box.BP.x && box.NP.x > box.Pairs.x); + assert.ok(box.Detect.y > box.IV.y && box.Manifold.y > box.Detect.y && box.Solve.y > box.Manifold.y && box.Pos.y > box.Solve.y); + assert.ok(box.IV.h > box.Acc.h && box.Acc.h > box.Frame.h); + assert.equal(l.edgeLr[g.edges.findIndex((e) => e.from === "BP")], true); + assert.equal(l.edgeLr[g.edges.findIndex((e) => e.from === "IV")], false); +}); + +// The artifact demo's map (lib/art/mock-artifacts.ts), and where it was +// drawn before subgraphs: a chart without them is laid out as it was. +const MAP = `graph LR + web[acme-shop web] --> edge(Edge / CDN) + edge --> suggest[Suggest API] + edge --> search[Search API] + suggest --> cache[(Suggest cache)] + search --> trigram[(Trigram index)] + search -. fallback .-> pg[(Postgres)] + search --> recs[Recs service] + recs --> pg + importer[Catalogue importer] --> pg + importer --> trigram`; + +test("a chart without subgraphs is laid out exactly as it was", () => { + const g = chart(MAP); + const l = layoutFlowchart(g); + assert.deepEqual([l.W, l.H], [1006, 158]); + const at = Object.fromEntries(Object.entries(l.box).map(([id, b]) => [id, [b.x, b.y, b.w, b.h]])); + assert.deepEqual(at, { + web: [0, 30, 150, 38], + importer: [0, 90, 150, 38], + edge: [214, 60, 150, 38], + suggest: [428, 30, 150, 38], + search: [428, 90, 150, 38], + cache: [642, 0, 150, 38], + trigram: [642, 60, 150, 38], + recs: [642, 120, 150, 38], + pg: [856, 60, 150, 38], + }); + assert.deepEqual(edge(g, "search", "pg"), { from: "search", to: "pg", label: "fallback", dotted: true, thick: false, arrow: true }); + + const td = layoutFlowchart(chart("graph TD\n a[Start] --> b[Load config]\n a --> c[Read env]\n b --> d{Valid?}\n c --> d\n d -->|yes| e[Run]\n d -->|no| f[Fail]\n e --> g((Done))")); + assert.deepEqual([td.W, td.H], [270, 410]); + assert.deepEqual( + Object.fromEntries(Object.entries(td.box).map(([id, b]) => [id, [b.x, b.y]])), + { a: [83.5, 0], b: [0, 93], c: [167, 93], d: [83.5, 186], e: [0, 279], f: [167, 279], g: [83.5, 372] }, + ); +}); + +test("a cycle is laid out along the flow, not pushed off the end", () => { + const l = layoutFlowchart(chart("graph LR\n a --> b --> c --> a\n c --> c")); + assert.ok(l.box.a.x < l.box.b.x && l.box.b.x < l.box.c.x); + assert.equal(l.W, 3 * l.box.a.w + 2 * 64); +}); + +test("what it can't read it leaves out, and what isn't a flowchart it doesn't take", () => { + const g = chart(`--- +title: Front matter +--- +%%{init: {"theme": "dark"}}%% +graph TD; A-->B; B-->C + classDef hot fill:#f96 + class A hot + style B fill:#bbf + linkStyle 0 stroke:#f00 + click A "https://example.com" + accTitle: Steps + this is not --> + ]]] ((( {{ + end + C --> D["Unclosed + E -->`); + assert.deepEqual(pairs(g), ["A>B", "B>C", "C>D"]); + assert.deepEqual( + g.nodes.map((n) => n.id), + ["A", "B", "C", "D", "E"].filter((id) => node(g, id)), + ); + assert.equal(parseFlowchart("sequenceDiagram\n A->>B: hi"), undefined); + assert.equal(parseFlowchart("pie title Pets"), undefined); + assert.equal(parseFlowchart(""), undefined); + // An empty subgraph is a box of its own; one never closed still holds what's in it. + const e = chart("flowchart LR\n subgraph empty[Nothing yet]\n end\n subgraph open\n a --> b"); + assert.deepEqual(e.groups.map((x) => x.id), ["empty", "open"]); + assert.deepEqual(e.nodes.map((n) => n.group), ["open", "open"]); + const l = layoutFlowchart(e); + assert.ok(l.box.empty.w > 0 && l.box.empty.h > 0); +}); diff --git a/app/src/lib/art/mermaid.ts b/app/src/lib/art/mermaid.ts index 19113b98..daf2625d 100644 --- a/app/src/lib/art/mermaid.ts +++ b/app/src/lib/art/mermaid.ts @@ -1,16 +1,39 @@ -// A small reader for the Mermaid flowcharts agents write most: graph or -// flowchart, LR/RL/TD/TB/BT, nodes in [ ], ( ), [( )], (( )), { }, edges -// -->, ---, -.->, ==>, with labels (-- x -->, -->|x|, -. x .->). Other +// A small reader for the Mermaid flowcharts agents write: graph or +// flowchart, LR/RL/TD/TB/BT; the usual node shapes ([ ], ( ), ([ ]), [[ ]], +// [( )], (( )), ((( ))), > ], { }, {{ }}, [/ /] and its slants, and +// A@{ shape: … }); edges -->, ---, -.->, ==>, ~~~, --o, --x, <-->, with +// labels (-- x -->, -->|x|, -. x .->, == x ==>), chains a --> b --> c and +// a & b --> c; and subgraphs, nested. Labels lose their quotes, a
+// breaks the line and entity codes (#quot;, &) are decoded. Other // diagram types (sequence, class, gantt…) are shown as their source. // Mermaid itself isn't bundled: its renderer is large and has had XSS bugs; // this draws text only. -export type Shape = "box" | "round" | "store" | "circle" | "diamond"; +export type Shape = + | "box" + | "round" + | "stadium" + | "subroutine" + | "store" + | "circle" + | "double-circle" + | "asym" + | "diamond" + | "hexagon" + | "lean-r" + | "lean-l" + | "trapezoid" + | "trapezoid-alt"; + +export type Dir = "LR" | "TD"; export interface MNode { id: string; + // Lines are split by "\n" (a
in the source). label: string; shape: Shape; + // The innermost subgraph it's in. + group?: string; } export interface MEdge { @@ -20,114 +43,584 @@ export interface MEdge { dotted?: boolean; thick?: boolean; arrow: boolean; + // An end other than an arrow: --o, --x. + tip?: "circle" | "cross"; + // Marked at both ends: <-->, o--o. + both?: boolean; + // ~~~: placed by, not drawn. + hidden?: boolean; +} + +export interface MGroup { + id: string; + label: string; + parent?: string; + dir?: Dir; } export interface Flowchart { - dir: "LR" | "TD"; + dir: Dir; nodes: MNode[]; edges: MEdge[]; + groups: MGroup[]; + // Nodes and subgraphs as they first appear, which layout keeps to. + order: string[]; +} + +// ---- Labels ---- + +const ENTITIES: Record = { quot: '"', amp: "&", lt: "<", gt: ">", apos: "'", nbsp: " ", ndash: "–", mdash: "—", hellip: "…", middot: "·", times: "×", deg: "°", larr: "←", rarr: "→", harr: "↔", uarr: "↑", darr: "↓", le: "≤", ge: "≥", ne: "≠", copy: "©", hearts: "♥", check: "✓" }; + +function entity(name: string): string | undefined { + if (name[0] !== "#") return ENTITIES[name.toLowerCase()]; + const n = /^#x/i.test(name) ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10); + return n >= 32 && n <= 0x10ffff ? String.fromCodePoint(n) : undefined; +} + +// labelText is a label as written made into the text drawn: its quotes +// off, a markdown string's backticks and ** gone,
a new line, other +// inline tags dropped, and Mermaid's #quot; codes and HTML's " +// decoded. It stays text: nothing here is ever HTML. +export function labelText(raw: string): string { + let s = raw.trim(); + if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s.endsWith(s[0])) s = s.slice(1, -1); + if (s.length >= 2 && s[0] === "`" && s.endsWith("`")) + s = s + .slice(1, -1) + .replace(/\*\*(.+?)\*\*|__(.+?)__/g, "$1$2") + .replace(/(^|[^\w*])\*(?!\s)(.+?)\*(?!\w)/g, "$1$2") + .replace(/(^|[^\w])_(?!\s)(.+?)_(?!\w)/g, "$1$2"); + s = s + .replace(//gi, "\n") + .replace(/<\/?(?:b|i|u|s|em|strong|small|big|code|sub|sup|span|font|mark|del|ins|p|div)\b[^>]*>/gi, "") + .replace(/(? entity(/^\d+$/.test(n) ? `#${n}` : n) ?? m) + .replace(/&(#\d+|#x[0-9a-f]+|\w+);/gi, (m, n: string) => entity(n) ?? m); + const lines = s.split("\n").map((l) => l.replace(/\s+/g, " ").trim()); + while (lines.length > 1 && !lines[0]) lines.shift(); + while (lines.length > 1 && !lines[lines.length - 1]) lines.pop(); + return lines.join("\n"); +} + +// ---- Parsing ---- + +// find is the first needle at or after from that isn't inside quotes; +// with the quotes unbalanced, the first anywhere. +function find(s: string, from: number, needle: string): number { + let quote = false; + for (let i = from; i < s.length; i++) { + if (s[i] === '"') quote = !quote; + else if (!quote && s.startsWith(needle, i)) return i; + } + return s.indexOf(needle, from); +} + +// statements splits a line at ;s outside quotes and brackets, leaving the +// ; of an entity code (#quot;, &) alone. +function statements(line: string): string[] { + const out: string[] = []; + let depth = 0; + let quote = false; + let start = 0; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '"') quote = !quote; + else if (quote) continue; + else if (c === "[" || c === "(" || c === "{") depth++; + else if (c === "]" || c === ")" || c === "}") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0 && !/[#&]#?\w+$/.test(line.slice(start, i))) { + out.push(line.slice(start, i)); + start = i + 1; + } + } + out.push(line.slice(start)); + return out.map((s) => s.trim()).filter(Boolean); } -const NODE = /^([A-Za-z0-9_][\w-]*)\s*(\[\((.+?)\)\]|\(\((.+?)\)\)|\{(.+?)\}|\[(.+?)\]|\((.+?)\))?/; -const EDGE = /^(.+?)\s*(-\.\s*(.+?)\s*\.->|-\.->|-\.-|==>\|(.+?)\||==\s*([^=|]+?)\s*==>|==>|--\s*([^->|]+?)\s*-->|-->\|(.+?)\||-->|---)\s*(.+)$/; +// The newer A@{ shape: … } names, those drawn; any other is a box. +const NAMED: Record = { + rounded: "round", + event: "round", + stadium: "stadium", + pill: "stadium", + terminal: "stadium", + subproc: "subroutine", + subroutine: "subroutine", + "fr-rect": "subroutine", + cyl: "store", + cylinder: "store", + database: "store", + db: "store", + circle: "circle", + circ: "circle", + "dbl-circ": "double-circle", + "double-circle": "double-circle", + odd: "asym", + diam: "diamond", + diamond: "diamond", + decision: "diamond", + question: "diamond", + hex: "hexagon", + hexagon: "hexagon", + prepare: "hexagon", + "lean-r": "lean-r", + "lean-right": "lean-r", + "in-out": "lean-r", + "lean-l": "lean-l", + "lean-left": "lean-l", + "out-in": "lean-l", + "trap-b": "trapezoid", + trapezoid: "trapezoid", + priority: "trapezoid", + "trap-t": "trapezoid-alt", + "inv-trapezoid": "trapezoid-alt", + manual: "trapezoid-alt", +}; -const clean = (s: string) => s.replace(/^["']|["']$/g, "").trim(); +const ID = /[\p{L}\p{N}_]+(?:-[\p{L}\p{N}_]+)*/uy; +const CLASS = /:::[\w-]+/y; +const EDGE_ID = /[\w-]+@(?=[-=, ---, +// -.->, -.-, ==>, ===, ~~~, --o, --x, <-->, o--o; longer ones too. +const PLAIN = /(<|[ox](?=[-=]))?(?:(-{2,}|={2,}|-\.+-)(>|[ox](?![\p{L}\p{N}_]))|(-{3,}|={3,}|-\.+-|~{3,}))\s*(?:\|([^|]*)\|)?/uy; +// An edge with its label in the middle: -- x -->, == x ==>, -. x .->, and +// the same with a quoted label, which may hold anything. +const ENDS = String.raw`(-{2,}>|-{3,}|={2,}>|={3,}|\.-+>|\.-+|-{2,}[ox]|={2,}[ox])(?![-=.>])`; +const QUOTED = new RegExp(String.raw`(<)?(--|==|-\.)\s*("[^"]*")\s*${ENDS}`, "y"); +const LABELLED = new RegExp(String.raw`(<)?(--|==|-\.)\s*(.*?)\s*${ENDS}`, "y"); + +interface Op { + label?: string; + dotted: boolean; + thick: boolean; + arrow: boolean; + tip?: "circle" | "cross"; + both: boolean; + hidden: boolean; +} + +const dirOf = (s: string | undefined): Dir => (/^(TD|TB|BT)$/i.test(s ?? "") ? "TD" : "LR"); export function parseFlowchart(src: string): Flowchart | undefined { - const lines = src + let lines = src .split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("%%")); - const head = lines.shift() ?? ""; - const hm = /^(graph|flowchart)\b\s*(LR|RL|TD|TB|BT)?/i.exec(head); + // Front matter (--- title: … ---) comes before the diagram. + if (lines[0] === "---") { + const close = lines.indexOf("---", 1); + lines = close > 0 ? lines.slice(close + 1) : lines; + } + const stmts = lines.flatMap(statements); + const head = stmts.shift() ?? ""; + const hm = /^(graph|flowchart)\b\S*\s*(LR|RL|TD|TB|BT)?/i.exec(head); if (!hm) return undefined; - const dir = /^(TD|TB|BT)$/i.test(hm[2] ?? "") ? "TD" : "LR"; + const dir = dirOf(hm[2]); const nodes = new Map(); + const groups = new Map(); + const order: string[] = []; const edges: MEdge[] = []; - const node = (s: string): string | undefined => { - const m = NODE.exec(s.trim()); - if (!m) return undefined; - const id = m[1]; - const label = m[3] ?? m[4] ?? m[5] ?? m[6] ?? m[7]; - const shape: Shape = m[3] ? "store" : m[4] ? "circle" : m[5] ? "diamond" : m[7] ? "round" : "box"; + const stack: string[] = []; + + const seen = (id: string) => { + if (!nodes.has(id) && !groups.has(id)) order.push(id); + }; + // within is whether group a is inside group b. + const within = (a: string | undefined, b: string) => { + for (let p = a, k = 0; p && k < 64; p = groups.get(p)?.parent, k++) if (p === b) return true; + return false; + }; + // A node is in the subgraph it's first mentioned in; mentioned first + // outside any, the next subgraph to mention it takes it, as does one + // inside the subgraph it's in. + const claim = (n: MNode) => { + const top = stack[stack.length - 1]; + if (top && top !== n.group && (n.group === undefined || within(top, n.group))) n.group = top; + }; + const node = (id: string, label?: string, shape: Shape = "box") => { + if (groups.has(id)) return id; + seen(id); const had = nodes.get(id); - if (!had || label) nodes.set(id, { id, label: clean(label ?? had?.label ?? id), shape: label ? shape : (had?.shape ?? "box") }); + // A later mention with a label relabels it; a bare one leaves it be. + let n = had; + if (!n || label !== undefined) { + n = { id, label: label === undefined ? id : labelText(label), shape: label === undefined ? "box" : shape }; + if (had?.group) n.group = had.group; + } + nodes.set(id, n); + claim(n); return id; }; - for (const raw of lines) { - const l = raw.replace(/;$/, ""); - if (/^(classDef|class|style|linkStyle|click|subgraph|end|direction)\b/.test(l)) continue; - // A chain, a --> b --> c, is read edge by edge. - let rest = l; - let from: string | undefined; - let guard = 0; - while (rest && guard++ < 20) { - const m = EDGE.exec(rest); - if (!m) { - const id = node(rest); - if (from && id) break; - break; + + const chain = (s: string) => { + let i = 0; + const ws = () => { + while (i < s.length && /\s/.test(s[i])) i++; + }; + const sticky = (re: RegExp) => { + re.lastIndex = i; + const m = re.exec(s); + if (m) i = re.lastIndex; + return m; + }; + // shapeAt reads a node's shape and label, if one follows its id. + const shapeAt = (): [string, Shape] | undefined => { + const c = s[i]; + const take = (open: number, close: string, shape: Shape): [string, Shape] | undefined => { + const j = find(s, i + open, close); + if (j < 0) return undefined; + const label = s.slice(i + open, j); + i = j + close.length; + return [label, shape]; + }; + if (c === "[") { + const j = find(s, i + 1, "]"); + if (j < 0) return undefined; + const inner = s.slice(i + 1, j); + if (inner[0] === "[" && s[j + 1] === "]") { + i = j + 2; + return [inner.slice(1), "subroutine"]; + } + i = j + 1; + if (inner.length >= 2 && inner[0] === "(" && inner.endsWith(")")) return [inner.slice(1, -1), "store"]; + if (inner.length >= 2 && (inner[0] === "/" || inner[0] === "\\") && /[/\\]$/.test(inner)) { + const [a, b] = [inner[0], inner[inner.length - 1]]; + return [inner.slice(1, -1), a === "/" ? (b === "/" ? "lean-r" : "trapezoid") : b === "\\" ? "lean-l" : "trapezoid-alt"]; + } + return [inner, "box"]; + } + if (c === "(") { + if (s.startsWith("(((", i)) return take(3, ")))", "double-circle"); + if (s.startsWith("((", i)) return take(2, "))", "circle"); + if (s.startsWith("([", i)) return take(2, "])", "stadium"); + return take(1, ")", "round"); + } + if (c === "{") return s.startsWith("{{", i) ? take(2, "}}", "hexagon") : take(1, "}", "diamond"); + if (c === ">") return take(1, "]", "asym"); + return undefined; + }; + const readNode = (): string | undefined => { + const m = sticky(ID); + if (!m) return undefined; + const id = m[0]; + sticky(CLASS); + if (s.startsWith("@{", i)) { + const j = find(s, i + 2, "}"); + const body = s.slice(i + 2, j < 0 ? s.length : j); + i = j < 0 ? s.length : j + 1; + const lm = /label\s*:\s*(?:"([^"]*)"|'([^']*)'|([^,}]+))/.exec(body); + const sm = /shape\s*:\s*([\w-]+)/.exec(body); + const label = lm ? (lm[1] ?? lm[2] ?? lm[3]) : undefined; + if (lm || sm) return node(id, label ?? nodes.get(id)?.label ?? id, NAMED[sm?.[1]?.toLowerCase() ?? ""] ?? "box"); + return node(id); + } + const shaped = shapeAt(); + sticky(CLASS); + return shaped ? node(id, shaped[0], shaped[1]) : node(id); + }; + const readNodes = (): string[] | undefined => { + const out: string[] = []; + for (;;) { + ws(); + const id = readNode(); + if (!id) return out.length ? out : undefined; + out.push(id); + ws(); + if (s[i] !== "&") return out; + i++; + } + }; + const readOp = (): Op | undefined => { + sticky(EDGE_ID); + const p = sticky(PLAIN); + const m = p ? undefined : (sticky(QUOTED) ?? sticky(LABELLED)); + const parts = p ? [p[1], p[2] ?? p[4], p[3] ?? "", p[5]] : m ? [m[1], m[2] + m[4], m[4].slice(-1), m[3]] : undefined; + if (!parts) return undefined; + const [tail, body, head, label] = parts; + const end = /[>ox]/.test(head) ? head : ""; + return { + label: label === undefined ? undefined : labelText(label).replace(/\n/g, " ") || undefined, + dotted: body.includes("."), + thick: body.includes("="), + arrow: end === ">", + tip: end === "o" ? "circle" : end === "x" ? "cross" : undefined, + both: !!tail, + hidden: body.startsWith("~"), + }; + }; + // read says whether the line was read: to its end, or as far as an + // edge at least. + const read = (): boolean => { + const had = edges.length; + let left = readNodes(); + if (!left) return false; + for (;;) { + ws(); + if (i >= s.length) return true; + const op = readOp(); + const right = op && readNodes(); + if (!op || !right) return edges.length > had; + for (const from of left) + for (const to of right) + edges.push({ from, to, label: op.label, dotted: op.dotted, thick: op.thick, arrow: op.arrow, ...(op.tip && { tip: op.tip }), ...(op.both && { both: true }), ...(op.hidden && { hidden: true }) }); + left = right; } - const left = from ?? node(m[1]); - const op = m[2]; - const label = m[3] ?? m[4] ?? m[5] ?? m[6] ?? m[7]; - // The right side may go on: b --> c. - const next = EDGE.exec(m[8]); - const rightText = next ? next[1] : m[8]; - const to = node(rightText); - if (left && to) edges.push({ from: left, to, label: label ? clean(label) : undefined, dotted: op.startsWith("-."), thick: op.startsWith("=="), arrow: op !== "---" && op !== "-.-" }); - if (!next) break; - from = to; - rest = m[8]; + }; + // A line that isn't one (prose, half an edge) adds nothing, not even + // the word it started with. + const kept = new Map([...nodes].map(([id, n]) => [id, { ...n }])); + const seenTo = order.length; + if (!read()) { + nodes.clear(); + for (const [id, n] of kept) nodes.set(id, n); + order.length = seenTo; + } + }; + + const subgraph = (rest: string) => { + let id: string; + let label: string; + const m = /^([^\s[\]"]+)\s*\[(.*)\]$/.exec(rest); + if (m) [id, label] = [m[1], m[2]]; + else [id, label] = [labelText(rest), rest]; + if (!id) id = `subgraph-${groups.size + 1}`; + const parent = stack[stack.length - 1]; + if (!groups.has(id) && !within(parent, id)) { + seen(id); + // Named in an edge before it was opened, it was taken for a node. + nodes.delete(id); + groups.set(id, { id, label: labelText(label), ...(parent && { parent }) }); + } + stack.push(id); + }; + + for (const s of stmts) { + try { + const sg = /^subgraph\b\s*(.*)$/.exec(s); + if (sg) subgraph(sg[1].trim()); + else if (s === "end") stack.pop(); + else if (/^direction\b/.test(s)) { + const top = groups.get(stack[stack.length - 1] ?? ""); + const d = /^direction\s+(LR|RL|TD|TB|BT)\b/i.exec(s); + if (top && d) top.dir = dirOf(d[1]); + } else if (/^(classDef|class|style|linkStyle|click|accTitle|accDescr)\b/.test(s)) continue; + else chain(s); + } catch { + // A line this can't read is left out, never the whole chart. } } - return { dir, nodes: [...nodes.values()], edges }; + return { dir, nodes: [...nodes.values()], edges, groups: [...groups.values()], order: order.filter((id) => nodes.has(id) || groups.has(id)) }; +} + +// ---- Layout ---- + +export interface Rect { + x: number; + y: number; + w: number; + h: number; } export interface Layout { - pos: Record; + // Every node's and subgraph's box. + box: Record; + // Each edge's flow: across (LR) or down, the flow of the subgraph that + // holds both its ends. + edgeLr: boolean[]; W: number; H: number; - NW: number; - NH: number; lr: boolean; } -// layout puts nodes in layers along the flow (longest path from a root), -// ordered within a layer by where their parents are. -export function layoutFlowchart(g: Flowchart): Layout { +// A node is NH tall, LINE more for each line after its first; nodes are +// as wide as the chart's longest line (up to CAP characters) or, in a +// chart with subgraphs, as their own, so the groups stay compact. +export const NH = 38; +export const LINE = 15; +export const CAP = 30; +const GX = 64; +const GY = 22; +// A subgraph's box: its padding, and the band its title sits in. +export const GPAD = 14; +export const GHEAD = 28; + +export const lines = (label: string) => label.split("\n"); +const longest = (label: string) => Math.max(...lines(label).map((l) => [...l].length)); +const nodeW = (chars: number) => Math.round(Math.max(8, Math.min(chars, CAP)) * 6.8 + 28); + +interface Item { + id: string; + w: number; + h: number; +} + +// backEdges are the edges that close a cycle, found depth first in the +// order things were written; layering leaves them out, so a loop doesn't +// push its nodes down without end. +function backEdges(items: Item[], edges: { from: string; to: string }[]): Set { + const out = new Map(); + edges.forEach((e, k) => out.set(e.from, [...(out.get(e.from) ?? []), k])); + const state = new Map(); + const back = new Set(); + const visit = (id: string) => { + state.set(id, 1); + for (const k of out.get(id) ?? []) { + const t = edges[k].to; + if (state.get(t) === 1) back.add(k); + else if (!state.has(t)) visit(t); + } + state.set(id, 2); + }; + for (const x of items) if (!state.has(x.id)) visit(x.id); + return back; +} + +// place puts items in layers along the flow (longest path from a root), +// ordered within a layer by where their parents are, each layer centred +// across the flow. +function place(items: Item[], edges: { from: string; to: string; dotted?: boolean }[], lr: boolean) { const layer: Record = {}; - for (const n of g.nodes) layer[n.id] = 0; - for (let i = 0; i < g.nodes.length; i++) for (const e of g.edges) if (layer[e.to] <= layer[e.from] && !(e.dotted && layer[e.to] > 0)) layer[e.to] = Math.min(layer[e.from] + 1, g.nodes.length); + for (const x of items) layer[x.id] = 0; + const back = backEdges(items, edges); + for (let i = 0; i < items.length; i++) + edges.forEach((e, k) => { + if (!back.has(k) && layer[e.to] <= layer[e.from] && !(e.dotted && layer[e.to] > 0)) layer[e.to] = Math.min(layer[e.from] + 1, items.length); + }); const n = Math.max(0, ...Object.values(layer)) + 1; - const cols: MNode[][] = Array.from({ length: n }, () => []); - for (const node of g.nodes) cols[layer[node.id]].push(node); - const at: Record = {}; - cols.forEach((c, ci) => { + const cols = Array.from({ length: n }, () => [] as Item[]); + for (const x of items) cols[layer[x.id]].push(x); + const layers = cols.filter((c) => c.length); + const along = (x: Item) => (lr ? x.w : x.h); + const across = (x: Item) => (lr ? x.h : x.w); + const gapAlong = lr ? GX : GY * 2.5; + const gapAcross = lr ? GY : GX; + // mid is an item's centre across the flow, from the middle line. + const mid: Record = {}; + const spans = layers.map((c, ci) => { if (ci > 0) { - const bary = (x: MNode) => { - const ps = g.edges.filter((e) => e.to === x.id && at[e.from] !== undefined).map((e) => at[e.from]); + const bary = (x: Item) => { + const ps = edges.filter((e) => e.to === x.id && mid[e.from] !== undefined).map((e) => mid[e.from]); return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : 0; }; c.sort((a, b) => bary(a) - bary(b)); } - c.forEach((x, i) => (at[x.id] = i - (c.length - 1) / 2)); + const total = c.reduce((t, x) => t + across(x), 0) + (c.length - 1) * gapAcross; + let off = -total / 2; + for (const x of c) { + mid[x.id] = off + across(x) / 2; + off += across(x) + gapAcross; + } + return total; + }); + const thick = layers.map((c) => Math.max(...c.map(along))); + const A = thick.reduce((t, x) => t + x, 0) + (layers.length - 1) * gapAlong; + const C = Math.max(0, ...spans); + const at: Record = {}; + let start = 0; + layers.forEach((c, ci) => { + for (const x of c) { + // Across, a layer's items are centred on it; down, their tops line + // up, so a node beside a tall subgraph sits by its top. + const a = start + (lr ? (thick[ci] - along(x)) / 2 : 0); + const b = C / 2 + mid[x.id] - across(x) / 2; + at[x.id] = lr ? { x: a, y: b } : { x: b, y: a }; + } + start += thick[ci] + gapAlong; + }); + return { at, W: lr ? A : C, H: lr ? C : A }; +} + +// layoutFlowchart lays a chart out level by level: a subgraph is laid out +// inside, then placed in its parent as one block of that size, so its +// nodes stay together and nested ones nest. An edge between levels counts, +// for placing, as one between the blocks that hold its ends. A subgraph's +// own direction holds unless a node in it links straight out, as Mermaid +// does. +export function layoutFlowchart(g: Flowchart): Layout { + const { groups } = g; + const nodeOf = new Map(g.nodes.map((n) => [n.id, n])); + const groupOf = new Map(groups.map((x) => [x.id, x])); + const parent = (id: string) => (nodeOf.has(id) ? nodeOf.get(id)?.group : groupOf.get(id)?.parent); + const rank = new Map(g.order.map((id, i) => [id, i])); + const kids = new Map(); + const all = [...nodeOf.keys(), ...groupOf.keys()]; + all.sort((a, b) => (rank.get(a) ?? 1e9) - (rank.get(b) ?? 1e9)); + for (const id of all) { + const p = parent(id); + kids.set(p, [...(kids.get(p) ?? []), id]); + } + const ancestors = (id: string) => { + const out: string[] = []; + for (let p = parent(id); p && out.length < 64; p = parent(p)) out.push(p); + return out; + }; + const inside = (id: string, gid: string) => ancestors(id).includes(gid); + const flow = new Map(); + const dirIn = (gid: string | undefined): Dir => { + if (gid === undefined) return g.dir; + const had = flow.get(gid); + if (had) return had; + const gr = groupOf.get(gid); + const up = dirIn(gr?.parent); + const leaks = g.edges.some((e) => nodeOf.has(e.from) && nodeOf.has(e.to) && inside(e.from, gid) !== inside(e.to, gid)); + const d = gr?.dir && !leaks ? gr.dir : up; + flow.set(gid, d); + return d; + }; + + const grouped = groups.length > 0; + const NW = nodeW(Math.max(0, ...g.nodes.map((n) => longest(n.label)))); + const levels = new Map>(); + const size = (id: string): { w: number; h: number } => { + const n = nodeOf.get(id); + if (n) return { w: grouped ? nodeW(longest(n.label)) : NW, h: NH + (lines(n.label).length - 1) * LINE }; + const l = level(id); + const title = [...(groupOf.get(id)?.label ?? "").replace(/\n/g, " ")].length * 6.6; + return { w: Math.max(l.W, title, 60) + GPAD * 2, h: l.H + GHEAD + GPAD }; + }; + const level = (gid: string | undefined): ReturnType => { + const had = levels.get(gid); + if (had) return had; + const ids = kids.get(gid) ?? []; + const here = new Set(ids); + // lift is the item at this level that holds id. + const lift = (id: string) => { + let x = id; + for (let k = 0; k < 64; k++) { + const p = parent(x); + if (p === gid) return x; + if (p === undefined) return undefined; + x = p; + } + return undefined; + }; + const edges = g.edges.flatMap((e) => { + const [from, to] = [lift(e.from), lift(e.to)]; + return from && to && from !== to && here.has(from) && here.has(to) ? [{ from, to, dotted: e.dotted }] : []; + }); + const l = place( + ids.map((id) => ({ id, ...size(id) })), + edges, + dirIn(gid) === "LR", + ); + levels.set(gid, l); + return l; + }; + + const box: Record = {}; + const put = (gid: string | undefined, ox: number, oy: number) => { + const l = level(gid); + for (const id of kids.get(gid) ?? []) { + const p = l.at[id]; + const s = size(id); + box[id] = { x: ox + p.x, y: oy + p.y, w: s.w, h: s.h }; + if (groupOf.has(id)) put(id, box[id].x + (s.w - level(id).W) / 2, box[id].y + GHEAD); + } + }; + put(undefined, 0, 0); + const root = level(undefined); + const edgeLr = g.edges.map((e) => { + const up = new Set(ancestors(e.to)); + return dirIn(ancestors(e.from).find((p) => up.has(p))) === "LR"; }); - const longest = Math.max(8, ...g.nodes.map((x) => Math.min(x.label.length, 24))); - const NW = Math.round(longest * 6.8 + 28); - const NH = 38; - const GX = 64; - const GY = 22; - const maxRows = Math.max(1, ...cols.map((c) => c.length)); - const lr = g.dir === "LR"; - const W = lr ? n * NW + (n - 1) * GX : maxRows * NW + (maxRows - 1) * GX; - const H = lr ? maxRows * NH + (maxRows - 1) * GY : n * NH + (n - 1) * GY * 2.5; - const pos: Record = {}; - cols.forEach((c, ci) => - c.forEach((x) => { - const k = at[x.id]; - pos[x.id] = lr ? { x: ci * (NW + GX), y: H / 2 - NH / 2 + k * (NH + GY) } : { x: W / 2 - NW / 2 + k * (NW + GX), y: ci * (NH + GY * 2.5) }; - }), - ); - return { pos, W, H, NW, NH, lr }; + return { box, edgeLr, W: root.W, H: root.H, lr: g.dir === "LR" }; } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 32143456..2ced3cb5 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -28,6 +28,14 @@ again. drawn from, and *Copy* still copies that. Other Mermaid diagrams, such as sequence diagrams, show as written, and a reply still being written shows its blocks as text until it's done. +- **Mermaid flowcharts draw as they were written.** Subgraphs show as + titled, tinted boxes around their nodes, nested where they nest, and a + `
` in a label starts a new line. Labels lose their quotes, codes + such as `#quot;` and `&` show as the characters they stand for, and + nodes keep their shapes: pills, cylinders, circles, hexagons and slanted + boxes. Every kind of arrow is read, labelled or not, as are chains like + `a --> b --> c` and `a & b --> c`. A loop back up the chart curves round + the side instead of stretching it. This goes for diagram artifacts too. - **Diffs in a reply look like the chat's edits.** A `diff` or `patch` code block draws with file names, line numbers and highlighting, as an agent's edits do. Plain lines of `+` and `-` show coloured, without