diff --git a/app/e2e/code-blocks.spec.ts b/app/e2e/code-blocks.spec.ts
new file mode 100644
index 00000000..11b04bad
--- /dev/null
+++ b/app/e2e/code-blocks.spec.ts
@@ -0,0 +1,205 @@
+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");
+
+// 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",
+ "",
+ `${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 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" } });
+ 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..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"
+ "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
new file mode 100644
index 00000000..12f78d40
--- /dev/null
+++ b/app/src/components/art/flowchart.tsx
@@ -0,0 +1,195 @@
+import { type CSSProperties, useId } from "react";
+
+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. 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;
+ 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)
+ 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;
+ 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 };
+}
+
+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. 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 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 (
+
+ );
+}
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 (
-
+
);
}
diff --git a/app/src/components/conversation/code-blocks.css b/app/src/components/conversation/code-blocks.css
new file mode 100644
index 00000000..e86aa595
--- /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-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); }
+.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 (
+