paseo-plugin-chat-view 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.compact-agent-activity +21 -0
  3. package/LICENSE.mermaid +21 -0
  4. package/README.md +428 -2
  5. package/README_zh.md +429 -0
  6. package/client/activity/Activity.test.ts +220 -0
  7. package/client/activity/Activity.tsx +784 -0
  8. package/client/activity/detail/detail.test.ts +102 -0
  9. package/client/activity/detail/dispatch.tsx +94 -0
  10. package/client/activity/detail/file.tsx +93 -0
  11. package/client/activity/detail/index.tsx +39 -0
  12. package/client/activity/detail/parts.tsx +221 -0
  13. package/client/activity/detail/search.tsx +59 -0
  14. package/client/activity/detail/terminal.tsx +53 -0
  15. package/client/activity/detail/texts.tsx +42 -0
  16. package/client/activity/disclosure.ts +49 -0
  17. package/client/activity/dom.test.ts +52 -0
  18. package/client/activity/dom.ts +138 -0
  19. package/client/activity/group-store.test.ts +126 -0
  20. package/client/activity/group-store.ts +265 -0
  21. package/client/activity/highlight.test.ts +116 -0
  22. package/client/activity/highlight.ts +418 -0
  23. package/client/activity/history.test.ts +602 -0
  24. package/client/activity/history.ts +239 -0
  25. package/client/activity/index.ts +74 -0
  26. package/client/activity/latest-marker.test.ts +41 -0
  27. package/client/activity/latest-marker.ts +70 -0
  28. package/client/activity/styles.ts +535 -0
  29. package/client/activity/tools/child-agent.tsx +308 -0
  30. package/client/activity/tools/exa.tsx +99 -0
  31. package/client/activity/tools/github.tsx +501 -0
  32. package/client/activity/tools/list.tsx +102 -0
  33. package/client/activity/tools/lists.test.ts +193 -0
  34. package/client/activity/tools/paseo/agent.tsx +384 -0
  35. package/client/activity/tools/paseo/browser.tsx +322 -0
  36. package/client/activity/tools/paseo/index.tsx +79 -0
  37. package/client/activity/tools/paseo/parts.tsx +133 -0
  38. package/client/activity/tools/paseo/provider.tsx +220 -0
  39. package/client/activity/tools/paseo/schedule.tsx +246 -0
  40. package/client/activity/tools/paseo/speak.tsx +20 -0
  41. package/client/activity/tools/paseo/terminal.tsx +214 -0
  42. package/client/activity/tools/paseo/workspace.tsx +162 -0
  43. package/client/activity/tools/shared.tsx +431 -0
  44. package/client/activity/transform.test.ts +28 -0
  45. package/client/activity/transform.ts +43 -0
  46. package/client/agent-pills.ts +56 -0
  47. package/client/dom.ts +75 -0
  48. package/client/enhance/controller.ts +111 -0
  49. package/client/enhance/dom.test.ts +28 -0
  50. package/client/enhance/dom.ts +101 -0
  51. package/client/enhance/index.ts +95 -0
  52. package/client/enhance/pill.tsx +19 -0
  53. package/client/enhance/state.ts +58 -0
  54. package/client/feature.ts +22 -0
  55. package/client/features.test.ts +149 -0
  56. package/client/features.ts +83 -0
  57. package/client/locale.ts +45 -0
  58. package/client/mermaid/diagram.tsx +438 -0
  59. package/client/mermaid/index.ts +27 -0
  60. package/client/mermaid/item.tsx +28 -0
  61. package/client/mermaid/markdown.tsx +217 -0
  62. package/client/mermaid/registration.ts +32 -0
  63. package/client/mermaid/toolbar.tsx +175 -0
  64. package/client/mermaid/viewer.tsx +314 -0
  65. package/client/readonly/dom.test.ts +66 -0
  66. package/client/readonly/dom.ts +32 -0
  67. package/client/readonly/index.ts +27 -0
  68. package/client/registration.test.ts +243 -0
  69. package/client/settings.tsx +438 -0
  70. package/client/sticky/StickyBar.tsx +144 -0
  71. package/client/sticky/dom.test.ts +230 -0
  72. package/client/sticky/dom.ts +517 -0
  73. package/client/sticky/index.ts +38 -0
  74. package/client/sticky/pill.tsx +70 -0
  75. package/client/timeline-pager.test.ts +121 -0
  76. package/client/timeline-pager.ts +123 -0
  77. package/index.client.tsx +44 -0
  78. package/index.server.ts +12 -0
  79. package/package.json +42 -4
  80. package/paseo-plugin.json +6 -0
  81. package/server/enhance/index.ts +63 -0
  82. package/server/enhance/manifest.ts +176 -0
  83. package/server/enhance/probe.test.ts +215 -0
  84. package/server/enhance/probe.ts +118 -0
  85. package/server/enhance/provider.ts +149 -0
  86. package/shared/activity/child-agent.test.ts +27 -0
  87. package/shared/activity/child-agent.ts +26 -0
  88. package/shared/activity/diff.test.ts +74 -0
  89. package/shared/activity/diff.ts +207 -0
  90. package/shared/activity/exa.test.ts +66 -0
  91. package/shared/activity/exa.ts +183 -0
  92. package/shared/activity/file-kind.test.ts +13 -0
  93. package/shared/activity/file-kind.ts +112 -0
  94. package/shared/activity/github.test.ts +56 -0
  95. package/shared/activity/github.ts +174 -0
  96. package/shared/activity/palette.test.ts +26 -0
  97. package/shared/activity/palette.ts +106 -0
  98. package/shared/activity/parse.test.ts +39 -0
  99. package/shared/activity/parse.ts +115 -0
  100. package/shared/activity/paseo-tools.test.ts +163 -0
  101. package/shared/activity/paseo-tools.ts +497 -0
  102. package/shared/activity/text.test.ts +85 -0
  103. package/shared/activity/text.ts +202 -0
  104. package/shared/activity/timeline.test.ts +26 -0
  105. package/shared/activity/timeline.ts +108 -0
  106. package/shared/activity/tool-presentation.test.ts +154 -0
  107. package/shared/activity/tool-presentation.ts +352 -0
  108. package/shared/enhance/contract.ts +105 -0
  109. package/shared/enhance/decide.ts +97 -0
  110. package/shared/enhance/enhance.test.ts +188 -0
  111. package/shared/enhance/failure.ts +40 -0
  112. package/shared/enhance/index.ts +8 -0
  113. package/shared/enhance/normalize.ts +16 -0
  114. package/shared/enhance/parse.ts +79 -0
  115. package/shared/enhance/policy.ts +48 -0
  116. package/shared/enhance/prompt.ts +134 -0
  117. package/shared/enhance/run.ts +84 -0
  118. package/shared/i18n.test.ts +81 -0
  119. package/shared/i18n.ts +300 -0
  120. package/shared/mermaid/flowchart.test.ts +169 -0
  121. package/shared/mermaid/flowchart.ts +271 -0
  122. package/shared/mermaid/layout.test.ts +305 -0
  123. package/shared/mermaid/layout.ts +496 -0
  124. package/shared/mermaid/parse.test.ts +110 -0
  125. package/shared/mermaid/parse.ts +193 -0
  126. package/shared/mermaid/segment.test.ts +55 -0
  127. package/shared/mermaid/segment.ts +95 -0
  128. package/shared/mermaid/sequence.test.ts +68 -0
  129. package/shared/mermaid/sequence.ts +130 -0
  130. package/shared/mermaid/transform.test.ts +52 -0
  131. package/shared/mermaid/transform.ts +45 -0
  132. package/shared/mermaid/zoom.test.ts +134 -0
  133. package/shared/mermaid/zoom.ts +114 -0
  134. package/shared/settings.ts +41 -0
  135. package/shared/sticky/history.test.ts +91 -0
  136. package/shared/sticky/history.ts +86 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * A parser for the Mermaid flowchart subset that actually shows up in agent
3
+ * output and documentation. Pure and dependency-free: Mermaid itself needs a DOM
4
+ * and cannot run in a Paseo client bundle, and the transformer must stay
5
+ * synchronous, so the grammar is walked by hand.
6
+ *
7
+ * Anything it fails to recognise is reported rather than guessed at, and the
8
+ * renderer falls back to showing the source.
9
+ */
10
+
11
+ export type Direction = "TD" | "TB" | "BT" | "LR" | "RL";
12
+ export type NodeShape =
13
+ | "rect"
14
+ | "round"
15
+ | "stadium"
16
+ | "subroutine"
17
+ | "diamond"
18
+ | "circle"
19
+ | "asymmetric";
20
+ export type EdgeStyle = "solid" | "dotted" | "thick";
21
+
22
+ export interface FlowNode {
23
+ id: string;
24
+ label: string;
25
+ shape: NodeShape;
26
+ }
27
+
28
+ export interface FlowEdge {
29
+ from: string;
30
+ to: string;
31
+ label: string | null;
32
+ style: EdgeStyle;
33
+ arrow: boolean;
34
+ }
35
+
36
+ /** A `subgraph` block: the box the renderer draws around its member nodes. */
37
+ export interface FlowSubgraph {
38
+ /** Mermaid's id when given, synthesized otherwise; edges never target it. */
39
+ id: string;
40
+ title: string;
41
+ members: string[];
42
+ }
43
+
44
+ export interface Flowchart {
45
+ direction: Direction;
46
+ nodes: FlowNode[];
47
+ edges: FlowEdge[];
48
+ /** Top-level `subgraph` blocks only; a nested one folds into its ancestor. */
49
+ subgraphs: FlowSubgraph[];
50
+ /** Lines the parser chose not to interpret, so the UI can say so. */
51
+ skipped: string[];
52
+ }
53
+
54
+ const HEADER = /^\s*(?:flowchart|graph)(?:\s+(TD|TB|BT|LR|RL))?\s*$/i;
55
+
56
+ /** Longest first: `-.->` must win over `-.-`. */
57
+ const OPERATORS: { token: string; style: EdgeStyle; arrow: boolean }[] = [
58
+ { token: "-.->", style: "dotted", arrow: true },
59
+ { token: "==>", style: "thick", arrow: true },
60
+ { token: "-->", style: "solid", arrow: true },
61
+ { token: "-.-", style: "dotted", arrow: false },
62
+ { token: "===", style: "thick", arrow: false },
63
+ { token: "---", style: "solid", arrow: false },
64
+ ];
65
+
66
+ const OPENERS: { open: string; close: string; shape: NodeShape }[] = [
67
+ { open: "([", close: "])", shape: "stadium" },
68
+ { open: "[[", close: "]]", shape: "subroutine" },
69
+ { open: "((", close: "))", shape: "circle" },
70
+ { open: "{{", close: "}}", shape: "diamond" },
71
+ { open: "[", close: "]", shape: "rect" },
72
+ { open: "(", close: ")", shape: "round" },
73
+ { open: "{", close: "}", shape: "diamond" },
74
+ { open: ">", close: "]", shape: "asymmetric" },
75
+ ];
76
+
77
+ /**
78
+ * Mermaid writes an edge label two ways. Rewriting `A -- yes --> B` into
79
+ * `A -->|yes| B` lets the splitter handle one form.
80
+ */
81
+ export function normalizeEdgeLabels(line: string): string {
82
+ return line
83
+ .replace(/--\s+([^->|]+?)\s+-->/g, "-->|$1|")
84
+ .replace(/-\.\s+([^.|>]+?)\s+\.->/g, "-.->|$1|")
85
+ .replace(/==\s+([^=|>]+?)\s+==>/g, "==>|$1|");
86
+ }
87
+
88
+ function stripQuotes(value: string): string {
89
+ const trimmed = value.trim();
90
+ const quoted = /^"(.*)"$/s.exec(trimmed) ?? /^'(.*)'$/s.exec(trimmed);
91
+ return (quoted ? quoted[1] : trimmed).replace(/<br\s*\/?>/gi, "\n").trim();
92
+ }
93
+
94
+ /** Reads `id`, `id[Label]`, `id{Decision}`, `id((Circle))` and friends. */
95
+ export function parseNodeExpression(raw: string): FlowNode | null {
96
+ const text = raw.trim();
97
+ if (text.length === 0) return null;
98
+
99
+ for (const { open, close, shape } of OPENERS) {
100
+ const start = text.indexOf(open);
101
+ if (start <= 0) continue;
102
+ if (!text.endsWith(close)) continue;
103
+ const id = text.slice(0, start).trim();
104
+ if (!/^[\w.-]+$/.test(id)) continue;
105
+ const label = stripQuotes(text.slice(start + open.length, text.length - close.length));
106
+ return { id, label: label.length > 0 ? label : id, shape };
107
+ }
108
+
109
+ if (!/^[\w.-]+$/.test(text)) return null;
110
+ return { id: text, label: text, shape: "rect" };
111
+ }
112
+
113
+ interface Split {
114
+ parts: string[];
115
+ operators: { style: EdgeStyle; arrow: boolean; label: string | null }[];
116
+ }
117
+
118
+ /** Splits `A -->|yes| B --> C` into its nodes and the operators between them. */
119
+ export function splitChain(line: string): Split | null {
120
+ const parts: string[] = [];
121
+ const operators: Split["operators"] = [];
122
+ let rest = line;
123
+
124
+ for (;;) {
125
+ let best: { index: number; op: (typeof OPERATORS)[number] } | null = null;
126
+ for (const op of OPERATORS) {
127
+ const index = rest.indexOf(op.token);
128
+ if (index < 0) continue;
129
+ if (
130
+ !best ||
131
+ index < best.index ||
132
+ (index === best.index && op.token.length > best.op.token.length)
133
+ ) {
134
+ best = { index, op };
135
+ }
136
+ }
137
+ if (!best) break;
138
+
139
+ parts.push(rest.slice(0, best.index));
140
+ let after = rest.slice(best.index + best.op.token.length);
141
+
142
+ let label: string | null = null;
143
+ const labelled = /^\s*\|([^|]*)\|/.exec(after);
144
+ if (labelled) {
145
+ label = stripQuotes(labelled[1]);
146
+ after = after.slice(labelled[0].length);
147
+ }
148
+
149
+ operators.push({ style: best.op.style, arrow: best.op.arrow, label });
150
+ rest = after;
151
+ }
152
+
153
+ if (operators.length === 0) return null;
154
+ parts.push(rest);
155
+ return { parts, operators };
156
+ }
157
+
158
+ export function parseFlowchart(source: string): Flowchart | null {
159
+ const lines = source.split("\n");
160
+ let direction: Direction | null = null;
161
+ const nodes = new Map<string, FlowNode>();
162
+ const edges: FlowEdge[] = [];
163
+ const subgraphs: FlowSubgraph[] = [];
164
+ const skipped: string[] = [];
165
+ /** Innermost-first stack of open blocks; only the bottom one is a drawn box. */
166
+ const open: FlowSubgraph[] = [];
167
+ /** Nested blocks open inside a drawn box; their ends must not pop the box. */
168
+ let nested = 0;
169
+
170
+ const remember = (node: FlowNode) => {
171
+ const existing = nodes.get(node.id);
172
+ // A later definition carrying a real label wins over a bare id mention.
173
+ if (!existing || (existing.label === existing.id && node.label !== node.id)) {
174
+ nodes.set(node.id, node);
175
+ }
176
+ if (open.length > 0) {
177
+ const box = open[0] as FlowSubgraph;
178
+ if (!box.members.includes(node.id)) box.members.push(node.id);
179
+ }
180
+ };
181
+
182
+ for (const original of lines) {
183
+ const line = original.replace(/%%.*$/, "").trim();
184
+ if (line.length === 0) continue;
185
+
186
+ const header = HEADER.exec(line);
187
+ if (header) {
188
+ direction = (header[1]?.toUpperCase() as Direction) ?? "TD";
189
+ continue;
190
+ }
191
+ if (direction === null) {
192
+ // Not a flowchart at all; let another parser have it.
193
+ return null;
194
+ }
195
+
196
+ if (/^subgraph\b/i.test(line)) {
197
+ // A nested block is not drawn as its own box: its members join the
198
+ // enclosing one, and the inner header is reported like any other
199
+ // uninterpreted line so nesting stays visible in the skipped list.
200
+ if (open.length > 0) {
201
+ nested += 1;
202
+ skipped.push(original.trim());
203
+ } else {
204
+ const box = parseSubgraphHeader(line, subgraphs.length);
205
+ if (box) {
206
+ subgraphs.push(box);
207
+ open.push(box);
208
+ } else skipped.push(original.trim());
209
+ }
210
+ continue;
211
+ }
212
+ if (/^end$/i.test(line)) {
213
+ if (nested > 0) nested -= 1;
214
+ else if (open.length > 0) open.pop();
215
+ else skipped.push(original.trim());
216
+ continue;
217
+ }
218
+ if (/^(?:style|classDef|class|click|linkStyle)\b/i.test(line)) {
219
+ skipped.push(original.trim());
220
+ continue;
221
+ }
222
+
223
+ const chain = splitChain(normalizeEdgeLabels(line));
224
+ if (!chain) {
225
+ const single = parseNodeExpression(line.replace(/;$/, ""));
226
+ if (single) remember(single);
227
+ else skipped.push(original.trim());
228
+ continue;
229
+ }
230
+
231
+ const parsed = chain.parts.map((part) => parseNodeExpression(part.replace(/;$/, "")));
232
+ if (parsed.some((node) => node === null)) {
233
+ skipped.push(original.trim());
234
+ continue;
235
+ }
236
+
237
+ for (const node of parsed) remember(node as FlowNode);
238
+ for (let index = 0; index < chain.operators.length; index += 1) {
239
+ const operator = chain.operators[index];
240
+ edges.push({
241
+ from: (parsed[index] as FlowNode).id,
242
+ to: (parsed[index + 1] as FlowNode).id,
243
+ label: operator.label,
244
+ style: operator.style,
245
+ arrow: operator.arrow,
246
+ });
247
+ }
248
+ }
249
+
250
+ if (direction === null) return null;
251
+ return { direction, nodes: [...nodes.values()], edges, subgraphs, skipped };
252
+ }
253
+
254
+ /**
255
+ * Reads the text after `subgraph`: `id["Title"]`, bare `id`, or a free-form
256
+ * (possibly quoted) title that gets a synthesized id because edges cannot
257
+ * reach a name with spaces anyway.
258
+ */
259
+ function parseSubgraphHeader(line: string, index: number): FlowSubgraph | null {
260
+ const rest = line.replace(/^subgraph\b/i, "").trim();
261
+ if (rest.length === 0) return null;
262
+
263
+ const labelled = /^([\w.-]+)\s*[[(]\s*(.*?)\s*[\])]$/.exec(rest);
264
+ if (labelled) {
265
+ const id = labelled[1] as string;
266
+ const title = stripQuotes(labelled[2] as string);
267
+ return { id, title: title.length > 0 ? title : id, members: [] };
268
+ }
269
+ if (/^[\w.-]+$/.test(rest)) return { id: rest, title: rest, members: [] };
270
+ return { id: `subgraph-${index}`, title: stripQuotes(rest), members: [] };
271
+ }
@@ -0,0 +1,305 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseFlowchart } from "./flowchart";
3
+ import {
4
+ assignLayers,
5
+ fitScale,
6
+ layoutFlowchart,
7
+ layoutSequence,
8
+ measureNode,
9
+ overflowsAfterFit,
10
+ } from "./layout";
11
+
12
+ const chart = (source: string) => parseFlowchart(source)!;
13
+
14
+ describe("measureNode", () => {
15
+ it("grows a node to fit its longest line", () => {
16
+ const narrow = measureNode({ id: "A", label: "A", shape: "rect" });
17
+ const wide = measureNode({ id: "B", label: "a much longer label", shape: "rect" });
18
+ expect(wide.width).toBeGreaterThan(narrow.width);
19
+ });
20
+
21
+ it("grows taller for a multi-line label", () => {
22
+ const one = measureNode({ id: "A", label: "one", shape: "rect" });
23
+ const two = measureNode({ id: "A", label: "one\ntwo", shape: "rect" });
24
+ expect(two.height).toBeGreaterThan(one.height);
25
+ });
26
+
27
+ it("keeps a circle square so the label fits inside it", () => {
28
+ const circle = measureNode({ id: "A", label: "hello", shape: "circle" });
29
+ expect(circle.width).toBe(circle.height);
30
+ });
31
+ });
32
+
33
+ describe("assignLayers", () => {
34
+ it("puts a chain on successive layers", () => {
35
+ const graph = chart("flowchart TD\n A --> B --> C");
36
+ const layers = assignLayers(graph.nodes, graph.edges);
37
+ expect([layers.get("A"), layers.get("B"), layers.get("C")]).toEqual([0, 1, 2]);
38
+ });
39
+
40
+ it("uses the longest path, not the first one found", () => {
41
+ // A -> C directly, and A -> B -> C. C belongs below B.
42
+ const graph = chart("flowchart TD\n A --> C\n A --> B\n B --> C");
43
+ const layers = assignLayers(graph.nodes, graph.edges);
44
+ expect(layers.get("C")).toBe(2);
45
+ });
46
+
47
+ it("terminates on a cycle instead of recursing forever", () => {
48
+ const graph = chart("flowchart TD\n A --> B\n B --> C\n C --> A");
49
+ const layers = assignLayers(graph.nodes, graph.edges);
50
+ expect(layers.size).toBe(3);
51
+ expect(Math.max(...layers.values())).toBeLessThan(3);
52
+ });
53
+
54
+ it("puts two independent roots on the same layer", () => {
55
+ const graph = chart("flowchart TD\n A --> C\n B --> C");
56
+ const layers = assignLayers(graph.nodes, graph.edges);
57
+ expect(layers.get("A")).toBe(layers.get("B"));
58
+ });
59
+ });
60
+
61
+ describe("layoutFlowchart", () => {
62
+ it("stacks layers downward for TD", () => {
63
+ const layout = layoutFlowchart(chart("flowchart TD\n A --> B"));
64
+ const [a, b] = ["A", "B"].map((id) => layout.nodes.find((node) => node.id === id)!);
65
+ expect(b.y).toBeGreaterThan(a.y);
66
+ expect(Math.abs(a.x - b.x)).toBeLessThan(1);
67
+ });
68
+
69
+ it("wraps a subgraph's members in one padded box with a title strip", () => {
70
+ const layout = layoutFlowchart(
71
+ chart("flowchart TD\n subgraph g[Group]\n A\n B\n end\n A --> B"),
72
+ );
73
+ const [box] = layout.subgraphs;
74
+ const members = layout.nodes.filter((node) => box.members.includes(node.id));
75
+ const left = Math.min(...members.map((node) => node.x));
76
+ const right = Math.max(...members.map((node) => node.x + node.width));
77
+ const top = Math.min(...members.map((node) => node.y));
78
+ const bottom = Math.max(...members.map((node) => node.y + node.height));
79
+ expect(box.title).toBe("Group");
80
+ expect(box.x).toBeLessThan(left);
81
+ expect(box.x + box.width).toBeGreaterThan(right);
82
+ expect(box.y + box.height).toBeGreaterThan(bottom);
83
+ // The title strip sits above the topmost member, not on top of it.
84
+ expect(box.y).toBeLessThan(top - 12);
85
+ // Nodes and edges stay in front: every node sits inside the canvas.
86
+ expect(Math.min(...layout.nodes.map((node) => node.x))).toBeGreaterThanOrEqual(0);
87
+ expect(Math.min(...layout.nodes.map((node) => node.y))).toBeGreaterThanOrEqual(0);
88
+ });
89
+
90
+ it("shifts everything when a box's padding crosses the origin", () => {
91
+ const layout = layoutFlowchart(chart("flowchart LR\n subgraph g[Group]\n A\n end\n A --> B"));
92
+ expect(Math.min(...layout.nodes.map((node) => node.x))).toBeGreaterThanOrEqual(0);
93
+ expect(Math.min(...layout.nodes.map((node) => node.y))).toBeGreaterThanOrEqual(0);
94
+ expect(Math.min(...layout.subgraphs.map((box) => box.x))).toBeGreaterThanOrEqual(0);
95
+ // The canvas grows to hold the box instead of clipping it.
96
+ for (const box of layout.subgraphs) {
97
+ expect(box.x + box.width).toBeLessThanOrEqual(layout.width);
98
+ expect(box.y + box.height).toBeLessThanOrEqual(layout.height);
99
+ }
100
+ });
101
+
102
+ it("routes a layer-skipping edge through a lane inside the canvas for TD", () => {
103
+ const layout = layoutFlowchart(chart("flowchart TD\n A --> B\n B --> C\n A --> C"));
104
+ for (const edge of layout.edges) {
105
+ for (const segment of edge.segments) {
106
+ expect(segment.x).toBeGreaterThanOrEqual(0);
107
+ expect(segment.x + segment.width).toBeLessThanOrEqual(layout.width);
108
+ expect(segment.y + segment.height).toBeLessThanOrEqual(layout.height);
109
+ }
110
+ }
111
+ });
112
+
113
+ it("stacks layers rightward for LR", () => {
114
+ const layout = layoutFlowchart(chart("flowchart LR\n A --> B"));
115
+ const [a, b] = ["A", "B"].map((id) => layout.nodes.find((node) => node.id === id)!);
116
+ expect(b.x).toBeGreaterThan(a.x);
117
+ });
118
+
119
+ it("flips the flow for BT", () => {
120
+ const layout = layoutFlowchart(chart("flowchart BT\n A --> B"));
121
+ const [a, b] = ["A", "B"].map((id) => layout.nodes.find((node) => node.id === id)!);
122
+ expect(b.y).toBeLessThan(a.y);
123
+ });
124
+
125
+ it("never overlaps two nodes on the same layer", () => {
126
+ const layout = layoutFlowchart(chart("flowchart TD\n R --> A\n R --> B\n R --> C"));
127
+ const row = layout.nodes
128
+ .filter((node) => node.layer === 1)
129
+ .sort((left, right) => left.x - right.x);
130
+ for (let index = 1; index < row.length; index += 1) {
131
+ expect(row[index].x).toBeGreaterThanOrEqual(row[index - 1].x + row[index - 1].width);
132
+ }
133
+ });
134
+
135
+ it("reports a canvas large enough to hold every node", () => {
136
+ const layout = layoutFlowchart(chart("flowchart TD\n R --> A\n R --> B\n A --> C"));
137
+ for (const node of layout.nodes) {
138
+ expect(node.x + node.width).toBeLessThanOrEqual(layout.width + 0.001);
139
+ expect(node.y + node.height).toBeLessThanOrEqual(layout.height + 0.001);
140
+ }
141
+ });
142
+
143
+ it("routes an edge with segments and points the arrow along the flow", () => {
144
+ const layout = layoutFlowchart(chart("flowchart TD\n A --> B"));
145
+ const edge = layout.edges[0];
146
+ expect(edge.segments).toHaveLength(3);
147
+ expect(edge.arrowAt?.direction).toBe("down");
148
+ });
149
+
150
+ it("gives a line no arrowhead", () => {
151
+ expect(layoutFlowchart(chart("flowchart TD\n A --- B")).edges[0].arrowAt).toBeNull();
152
+ });
153
+
154
+ it("places an edge label between its endpoints", () => {
155
+ const layout = layoutFlowchart(chart("flowchart TD\n A -->|yes| B"));
156
+ const [a, b] = ["A", "B"].map((id) => layout.nodes.find((node) => node.id === id)!);
157
+ const label = layout.edges[0].labelAt!;
158
+ expect(label.y).toBeGreaterThan(a.y);
159
+ expect(label.y).toBeLessThan(b.y + b.height);
160
+ });
161
+
162
+ it("survives an edge pointing at a node that was never defined", () => {
163
+ const layout = layoutFlowchart({
164
+ direction: "TD",
165
+ nodes: [],
166
+ edges: [{ from: "A", to: "B", label: null, style: "solid", arrow: true }],
167
+ subgraphs: [],
168
+ skipped: [],
169
+ });
170
+ expect(layout.edges[0].segments).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe("layoutSequence", () => {
175
+ const people = [
176
+ { id: "U", label: "User", actor: false },
177
+ { id: "S", label: "Payment gateway", actor: true },
178
+ ];
179
+
180
+ it("sizes each column to its own label", () => {
181
+ const layout = layoutSequence(people, 2);
182
+ expect(layout.columns[1].width).toBeGreaterThan(layout.columns[0].width);
183
+ });
184
+
185
+ it("never overlaps two columns", () => {
186
+ const layout = layoutSequence(people, 1);
187
+ const [first, second] = layout.columns;
188
+ expect(second.centerX - second.width / 2).toBeGreaterThan(first.centerX + first.width / 2);
189
+ });
190
+
191
+ it("stacks one row per event below the header", () => {
192
+ const layout = layoutSequence(people, 3);
193
+ expect(layout.rows).toHaveLength(3);
194
+ expect(layout.rows[0].y).toBeGreaterThanOrEqual(layout.headerHeight);
195
+ expect(layout.rows[2].y).toBeGreaterThan(layout.rows[1].y);
196
+ });
197
+
198
+ it("reports a canvas that contains the last column and row", () => {
199
+ const layout = layoutSequence(people, 2);
200
+ const last = layout.columns[layout.columns.length - 1];
201
+ expect(last.centerX + last.width / 2).toBeLessThanOrEqual(layout.width + 0.001);
202
+ expect(layout.height).toBeGreaterThan(layout.rows[1].y);
203
+ });
204
+
205
+ it("handles a diagram with no events yet", () => {
206
+ expect(layoutSequence(people, 0).rows).toEqual([]);
207
+ });
208
+ });
209
+
210
+ describe("diamond geometry", () => {
211
+ it("gives a diamond a square box, so its rotated corners reach the edges", () => {
212
+ const box = measureNode({ id: "A", label: "Has mermaid?", shape: "diamond" });
213
+ expect(box.width).toBe(box.height);
214
+ });
215
+
216
+ it("keeps the box wide enough for the label across the diagonal", () => {
217
+ const plain = measureNode({ id: "A", label: "Has mermaid?", shape: "rect" });
218
+ const diamond = measureNode({ id: "A", label: "Has mermaid?", shape: "diamond" });
219
+ expect(diamond.width).toBeGreaterThan(plain.width);
220
+ });
221
+ });
222
+
223
+ describe("fitScale", () => {
224
+ it("shrinks a wide diagram to the width it was given", () => {
225
+ expect(fitScale(900, 1200)).toBeCloseTo(0.75);
226
+ });
227
+
228
+ it("grows a small diagram so it does not sit lost in a wide row", () => {
229
+ expect(fitScale(900, 300)).toBe(1.3);
230
+ });
231
+
232
+ it("never shrinks past the point where labels stop being readable", () => {
233
+ expect(fitScale(100, 2000)).toBe(0.55);
234
+ });
235
+
236
+ it("leaves the drawing alone before the container has been measured", () => {
237
+ expect(fitScale(0, 800)).toBe(1);
238
+ expect(fitScale(600, 0)).toBe(1);
239
+ });
240
+
241
+ it("reports the case where scrolling is still needed after shrinking", () => {
242
+ expect(overflowsAfterFit(100, 2000)).toBe(true);
243
+ });
244
+
245
+ it("reports no overflow once the diagram fits", () => {
246
+ expect(overflowsAfterFit(900, 1200)).toBe(false);
247
+ expect(overflowsAfterFit(900, 300)).toBe(false);
248
+ });
249
+
250
+ it("scrolls again once the reader zooms a fitted drawing past the row", () => {
251
+ expect(overflowsAfterFit(900, 1200, 1)).toBe(false);
252
+ expect(overflowsAfterFit(900, 1200, 1.25)).toBe(true);
253
+ expect(overflowsAfterFit(100, 2000, 0.5)).toBe(true);
254
+ });
255
+ });
256
+
257
+ describe("edges that skip a layer", () => {
258
+ // Cached? --> Response jumps two layers past Query database and Write cache.
259
+ const source = `flowchart LR
260
+ Req([Request]) --> Auth{Authorized?}
261
+ Auth --> Cache{Cached?}
262
+ Cache -->|miss| Query[[Query database]]
263
+ Cache -->|hit| Serve((Response))
264
+ Query --> Store(Write cache)
265
+ Store --> Serve`;
266
+
267
+ const layout = layoutFlowchart(parseFlowchart(source)!);
268
+ const long = layout.edges.find((edge) => edge.from === "Cache" && edge.to === "Serve")!;
269
+ const boxes = layout.nodes.filter((node) => node.id !== "Cache" && node.id !== "Serve");
270
+
271
+ it("routes the long edge with five segments rather than three", () => {
272
+ expect(long.segments).toHaveLength(5);
273
+ const direct = layout.edges.find((edge) => edge.from === "Query" && edge.to === "Store")!;
274
+ expect(direct.segments).toHaveLength(3);
275
+ });
276
+
277
+ it("keeps every segment of the long edge out of the boxes it skips", () => {
278
+ for (const segment of long.segments) {
279
+ for (const box of boxes) {
280
+ const overlaps =
281
+ segment.x < box.x + box.width &&
282
+ segment.x + segment.width > box.x &&
283
+ segment.y < box.y + box.height &&
284
+ segment.y + segment.height > box.y;
285
+ expect(overlaps, `${long.from}->${long.to} crosses ${box.id}`).toBe(false);
286
+ }
287
+ }
288
+ });
289
+
290
+ it("grows the canvas so the lane is inside it", () => {
291
+ const lowest = Math.max(...long.segments.map((segment) => segment.y + segment.height));
292
+ expect(lowest).toBeLessThanOrEqual(layout.height);
293
+ });
294
+
295
+ it("still points the arrow into the target", () => {
296
+ const target = layout.nodes.find((node) => node.id === "Serve")!;
297
+ expect(long.arrowAt?.direction).toBe("right");
298
+ expect(long.arrowAt?.x).toBeCloseTo(target.x);
299
+ });
300
+
301
+ it("puts the label on the lane, clear of the nodes", () => {
302
+ expect(long.label).toBe("hit");
303
+ for (const box of boxes) expect(long.labelAt!.y).toBeGreaterThan(box.y + box.height);
304
+ });
305
+ });