@tangle-network/sandbox-ui 0.113.7 → 0.114.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 (106) hide show
  1. package/dist/WorkflowGraph-B-NsdYz_.js +1961 -0
  2. package/dist/assets.d.ts +214 -206
  3. package/dist/assets.js +1057 -908
  4. package/dist/auth-CWe17Hly.js +37 -0
  5. package/dist/auth.d.ts +2 -30
  6. package/dist/auth.js +2 -15
  7. package/dist/chat-BOvFAe6p.js +903 -0
  8. package/dist/chat.d.ts +2 -230
  9. package/dist/chat.js +2 -34
  10. package/dist/connectors.d.ts +52 -49
  11. package/dist/connectors.js +595 -643
  12. package/dist/dashboard-CZ3VgNPR.js +4969 -0
  13. package/dist/dashboard.d.ts +4 -1297
  14. package/dist/dashboard.js +4 -152
  15. package/dist/editor.d.ts +2 -1
  16. package/dist/editor.js +2 -31
  17. package/dist/files.d.ts +2 -6
  18. package/dist/files.js +2 -33
  19. package/dist/globals.css +63 -3
  20. package/dist/hooks.d.ts +146 -148
  21. package/dist/hooks.js +222 -55
  22. package/dist/index-CKRaj1pM.d.ts +776 -0
  23. package/dist/index-CXX5IpH2.d.ts +2 -0
  24. package/dist/index-DPePq7c8.d.ts +1340 -0
  25. package/dist/index-DiyLLZuJ.d.ts +230 -0
  26. package/dist/index-DvmZLMT1.d.ts +30 -0
  27. package/dist/index-I8ZLVtHd.d.ts +330 -0
  28. package/dist/index-PRM636Ex.d.ts +86 -0
  29. package/dist/index.d.ts +21 -33
  30. package/dist/index.js +21 -701
  31. package/dist/info-panel-C9cqDzOL.js +594 -0
  32. package/dist/integrations.d.ts +133 -141
  33. package/dist/integrations.js +491 -673
  34. package/dist/markdown.d.ts +2 -1
  35. package/dist/markdown.js +2 -13
  36. package/dist/model-I-NIxeM-.js +1250 -0
  37. package/dist/model-brand-B9A4H9pJ.d.ts +98 -0
  38. package/dist/model-brand-hMyDTUqB.js +624 -0
  39. package/dist/motion-DkyBRJb9.js +37 -0
  40. package/dist/openui.d.ts +2 -1
  41. package/dist/openui.js +2 -7
  42. package/dist/pages.d.ts +307 -300
  43. package/dist/pages.js +4404 -4181
  44. package/dist/primitives-Dh1vG6J3.js +113 -0
  45. package/dist/primitives.d.ts +2 -74
  46. package/dist/primitives.js +2 -204
  47. package/dist/provider-logo-DjqmConF.js +311 -0
  48. package/dist/run.d.ts +2 -1
  49. package/dist/run.js +2 -23
  50. package/dist/sdk-hooks.d.ts +3 -4
  51. package/dist/sdk-hooks.js +3 -31
  52. package/dist/sidecar-DgH_vQ6m.d.ts +9 -0
  53. package/dist/stores.d.ts +14 -15
  54. package/dist/stores.js +13 -81
  55. package/dist/styles.css +63 -3
  56. package/dist/template-card-L-4gmjx2.d.ts +90 -0
  57. package/dist/terminal-CN9ZgHRt.js +197 -0
  58. package/dist/terminal.d.ts +60 -60
  59. package/dist/terminal.js +2 -10
  60. package/dist/tokens.css +51 -0
  61. package/dist/types.d.ts +4 -11
  62. package/dist/types.js +0 -1
  63. package/dist/use-pty-session-C8cE0adT.js +669 -0
  64. package/dist/use-sidecar-auth-CCTJcrq7.d.ts +145 -0
  65. package/dist/use-sidecar-auth-DkdcufOz.js +742 -0
  66. package/dist/utils-DzWbktWG.js +2 -0
  67. package/dist/utils.d.ts +2 -1
  68. package/dist/utils.js +2 -37
  69. package/dist/workbench-BCaCKl-J.js +8347 -0
  70. package/dist/workbench.d.ts +2 -310
  71. package/dist/workbench.js +2 -44
  72. package/dist/workflows.d.ts +341 -339
  73. package/dist/workflows.js +74 -109
  74. package/dist/workspace-B6AtI6ld.js +2510 -0
  75. package/dist/workspace.d.ts +2 -761
  76. package/dist/workspace.js +2 -51
  77. package/package.json +46 -40
  78. package/dist/WorkflowGraph-2QOORYIR.js +0 -1722
  79. package/dist/chunk-2BUPSB7O.js +0 -0
  80. package/dist/chunk-3KBNPQQF.js +0 -2697
  81. package/dist/chunk-3LIXVEEE.js +0 -279
  82. package/dist/chunk-72UF2H5H.js +0 -707
  83. package/dist/chunk-AI25G5MX.js +0 -944
  84. package/dist/chunk-ANSC5AFC.js +0 -314
  85. package/dist/chunk-AZ3AWMTM.js +0 -8
  86. package/dist/chunk-BGVEHENC.js +0 -10
  87. package/dist/chunk-CZ7CSFO4.js +0 -39
  88. package/dist/chunk-DYXXYGS4.js +0 -4700
  89. package/dist/chunk-EI44GEQ5.js +0 -6
  90. package/dist/chunk-IACRDXER.js +0 -219
  91. package/dist/chunk-IOB2PW5Z.js +0 -55
  92. package/dist/chunk-M4K7ZGR2.js +0 -24
  93. package/dist/chunk-MYZAW4RX.js +0 -665
  94. package/dist/chunk-OMUIPJQZ.js +0 -555
  95. package/dist/chunk-RQZRXKMR.js +0 -398
  96. package/dist/chunk-SOKKTB7W.js +0 -230
  97. package/dist/chunk-TADGS4Q7.js +0 -1010
  98. package/dist/chunk-UCQNOUN3.js +0 -7964
  99. package/dist/chunk-ULPLRBMF.js +0 -34
  100. package/dist/chunk-WID73FPH.js +0 -89
  101. package/dist/chunk-X3YXP7FG.js +0 -38
  102. package/dist/logo-DiAjq759.d.ts +0 -15
  103. package/dist/model-brand-BNnaUKKZ.d.ts +0 -99
  104. package/dist/ports-list-CDIvTNnX.d.ts +0 -17
  105. package/dist/sdk-hooks-Djkjq4cp.d.ts +0 -145
  106. package/dist/template-card-Bn-Bw5xC.d.ts +0 -87
@@ -0,0 +1,1961 @@
1
+ import { r as modelBrandFor, t as ModelBrandStack } from "./model-brand-hMyDTUqB.js";
2
+ import { a as buildWorkflowGraph, c as triggerNodeIndex, d as providerLabel, f as shortModel, g as fmtTokens, h as fmtDuration, l as wfEdgeId, m as fmtCost, o as isStructuralKind, p as clampPreview, t as COMPACT_NODE_SIZE, u as worstSeverity } from "./model-I-NIxeM-.js";
3
+ import { t as ProviderIcon } from "./provider-logo-DjqmConF.js";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
6
+ import { AlertTriangle, Bell, Box, Cable, Camera, Circle, Clock, Code, GitBranch, Maximize2, Minimize2, Plus, Repeat, ScanSearch, Sparkles, UserCheck, Webhook, X, Zap } from "lucide-react";
7
+ import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlow, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState } from "@xyflow/react";
8
+ import "@xyflow/react/dist/style.css";
9
+ //#region src/workflows/flow-graph.ts
10
+ /**
11
+ * React Flow node/edge construction for the workflow graph. Kept separate from
12
+ * the React-Flow-agnostic parsing in `model.ts` and from the component itself so
13
+ * the live `nodeState` merge — the load-bearing wiring of the run view — is pure
14
+ * and unit-testable without rendering React Flow.
15
+ */
16
+ /** Edge type name for the custom renderer that draws guard chips and cycle
17
+ * badges. Used ONLY by edges that need one — everything else stays on the
18
+ * built-in `smoothstep`, so a graph without a declared topology renders
19
+ * exactly as it always has. */
20
+ const WF_EDGE_TYPE = "wfEdge";
21
+ /** The layouter's compass sides in React Flow's terms — the one place the
22
+ * RF-agnostic layout vocabulary is translated. */
23
+ const SIDE_POSITION = {
24
+ left: Position.Left,
25
+ right: Position.Right,
26
+ top: Position.Top,
27
+ bottom: Position.Bottom
28
+ };
29
+ /** A bare run-state map (`buildFlowGraph(yaml, { a0: { status: "running" } })`),
30
+ * recognized by what it CONTAINS: every value is a run state, i.e. an object with
31
+ * a `status`. */
32
+ function isNodeStateMap(arg) {
33
+ const values = Object.values(arg);
34
+ return values.length > 0 && values.every((v) => v !== null && typeof v === "object" && "status" in v);
35
+ }
36
+ /**
37
+ * Every key {@link BuildFlowGraphOptions} declares. An options object is
38
+ * recognized by carrying one of them, so a key missing here makes the whole
39
+ * object fall through to the run-state test below — which it fails, silently
40
+ * losing ALL of its options rather than the one that was forgotten.
41
+ *
42
+ * The completeness assertion under it turns that into a BUILD error: adding an
43
+ * option without adding it here stops compiling, rather than shipping a graph
44
+ * that quietly ignores the caller. Written as a tuple whose membership the
45
+ * option keys must be assignable to.
46
+ */
47
+ const FLOW_GRAPH_OPTION_KEYS = [
48
+ "nodeState",
49
+ "direction",
50
+ "compact",
51
+ "wrap",
52
+ "edges",
53
+ "reserveEdgeInsert"
54
+ ];
55
+ /**
56
+ * Accept either an options object or a bare `nodeState` map as the second arg, so
57
+ * an older positional caller keeps working instead of silently losing its run
58
+ * state.
59
+ *
60
+ * Both branches are POSITIVE tests — known option keys, or values that are run
61
+ * states. Nothing falls through to "must be a node-state map", because that is how
62
+ * an argument the graph doesn't understand (a dropped option, a typo, a key from a
63
+ * newer version) gets read as run state: `nodeState` would then be defined, which
64
+ * is the signal for "a run overlay is in play", and the graph would silently
65
+ * reserve run rows for a definition that has no run at all. An unrecognized shape
66
+ * is no options, not imaginary run state.
67
+ */
68
+ function normalizeFlowGraphOptions(arg) {
69
+ if (!arg) return {};
70
+ if (FLOW_GRAPH_OPTION_KEYS.some((key) => key in arg)) return arg;
71
+ return isNodeStateMap(arg) ? { nodeState: arg } : {};
72
+ }
73
+ /**
74
+ * Build the React Flow nodes/edges from workflow YAML, merging any live
75
+ * `nodeState` (keyed by node id) onto the matching node's data so the node
76
+ * component renders status/cost/output without a separate channel. A node with
77
+ * no entry in `nodeState` keeps its static data object unchanged (same
78
+ * reference), so the static-definition preview path is untouched. Each node
79
+ * carries its authoritative size and orientation-driven handle sides so the
80
+ * card renders at exactly the laid-out box and edges enter/leave the right edge.
81
+ */
82
+ function buildFlowGraph(yaml, optionsOrNodeState) {
83
+ const options = normalizeFlowGraphOptions(optionsOrNodeState);
84
+ const nodeState = options.nodeState;
85
+ const direction = options.direction ?? "LR";
86
+ const compact = options.compact ?? false;
87
+ const graph = buildWorkflowGraph(yaml, {
88
+ reserveRunState: nodeState !== void 0,
89
+ direction,
90
+ compact,
91
+ ...options.wrap ? { wrap: true } : {},
92
+ ...options.edges ? { edges: options.edges } : {},
93
+ ...options.reserveEdgeInsert ? { reserveEdgeInsert: true } : {}
94
+ });
95
+ const isLR = direction === "LR";
96
+ const sourcePosition = isLR ? Position.Right : Position.Bottom;
97
+ const targetPosition = isLR ? Position.Left : Position.Top;
98
+ return {
99
+ error: graph.error,
100
+ nodes: graph.nodes.map((n) => ({
101
+ id: n.id,
102
+ type: "wfNode",
103
+ position: n.position,
104
+ width: n.width,
105
+ height: n.height,
106
+ deletable: false,
107
+ style: {
108
+ width: n.width,
109
+ height: n.height
110
+ },
111
+ sourcePosition: n.sourceSide ? SIDE_POSITION[n.sourceSide] : sourcePosition,
112
+ targetPosition: n.targetSide ? SIDE_POSITION[n.targetSide] : targetPosition,
113
+ data: nodeState?.[n.id] ? {
114
+ ...n.data,
115
+ state: nodeState[n.id]
116
+ } : n.data
117
+ })),
118
+ edges: graph.edges.map((e) => {
119
+ const decorated = e.whenLabel !== void 0 || e.backEdge === true;
120
+ return {
121
+ id: e.id,
122
+ source: e.source,
123
+ target: e.target,
124
+ type: decorated ? WF_EDGE_TYPE : "smoothstep",
125
+ data: {
126
+ kind: e.kind,
127
+ ...e.whenLabel !== void 0 ? { whenLabel: e.whenLabel } : {},
128
+ ...e.backEdge === true ? { backEdge: true } : {}
129
+ }
130
+ };
131
+ })
132
+ };
133
+ }
134
+ /** Shallow-equal two run states, so an unchanged node can be skipped on a tick.
135
+ * Compares over the union of keys rather than a hand-listed field set, so a new
136
+ * WfNodeState field can't be silently ignored here (which would leave a node
137
+ * stale when only that field changed). WfNodeState is a flat record of
138
+ * primitives, so a shallow compare is exact. */
139
+ function sameRunState(a, b) {
140
+ if (a === b) return true;
141
+ if (!a || !b) return false;
142
+ const aKeys = Object.keys(a);
143
+ const bKeys = Object.keys(b);
144
+ if (aKeys.length !== bKeys.length) return false;
145
+ return aKeys.every((k) => a[k] === b[k]);
146
+ }
147
+ /**
148
+ * Merge live `nodeState` onto an EXISTING React Flow node list, returning a new
149
+ * list that updates only each node's `data` and preserves its identity (object
150
+ * reference), position, and React-Flow-measured size. The static structure comes
151
+ * from `baseDataById` (the definition-only node data, keyed by id), NOT from the
152
+ * previous node, so `state` never accumulates across ticks. A node whose state is
153
+ * unchanged is returned AS-IS (same reference) so React Flow doesn't re-render or
154
+ * re-measure it — this is what keeps a poll/SSE tick from blanking the canvas.
155
+ */
156
+ function mergeRunState(prev, baseDataById, nodeState) {
157
+ return prev.map((n) => {
158
+ const base = baseDataById.get(n.id) ?? n.data;
159
+ const s = nodeState?.[n.id];
160
+ const nextData = s ? {
161
+ ...base,
162
+ state: s
163
+ } : base;
164
+ return sameRunState(n.data.state, nextData.state) ? n : {
165
+ ...n,
166
+ data: nextData
167
+ };
168
+ });
169
+ }
170
+ /** Group problems by node id and by edge id (see {@link wfEdgeId}). */
171
+ function indexProblems(problems) {
172
+ const byNode = /* @__PURE__ */ new Map();
173
+ const byEdge = /* @__PURE__ */ new Map();
174
+ const named = (value) => typeof value === "string" && value !== "";
175
+ for (const problem of problems ?? []) {
176
+ const into = problem.anchor === "node" ? named(problem.node) ? byNode : null : problem.anchor === "edge" && named(problem.from) && named(problem.to) ? byEdge : null;
177
+ if (!into) continue;
178
+ const key = problem.anchor === "node" ? problem.node : wfEdgeId(problem.from, problem.to);
179
+ const at = into.get(key);
180
+ if (at) at.push(problem);
181
+ else into.set(key, [problem]);
182
+ }
183
+ return {
184
+ byNode,
185
+ byEdge
186
+ };
187
+ }
188
+ /** How far past a node's edge a cluster's own EDGE must sit to clear the card. */
189
+ const INSERT_CLEARANCE = 8;
190
+ /**
191
+ * Half the box a cluster occupies, in flow units, by what it carries.
192
+ *
193
+ * The label layer lives inside the zoomed viewport, so a 20px control is 20
194
+ * units at every zoom and these numbers hold at any scale. They are bounds
195
+ * rather than measurements: the control is a fixed 20px square, and the chip
196
+ * TRUNCATES at Tailwind's `max-w-40`, so 160 is the widest it can ever render.
197
+ * Nothing here has to be measured, which is what keeps the placement
198
+ * collision-free by construction rather than by a measure-then-reflow pass.
199
+ *
200
+ * Both axes are given because the cluster is not square and the flow direction
201
+ * decides which of them is the cross axis: a chip is wide and short, so in "LR"
202
+ * its width spans the corridor while in "TB" that same width is what has to
203
+ * clear the cards.
204
+ */
205
+ const CLUSTER_HALF_SIZE = {
206
+ /** The insert control alone: a 20-unit square. */
207
+ control: {
208
+ width: 10,
209
+ height: 10
210
+ },
211
+ /**
212
+ * A problem chip — and, since the two stack in one column, the chip WITH the
213
+ * control under it, which is the taller of the two and so the one the bound
214
+ * has to cover. Measured on the rendered page at 160 x 38.5 flow units (the
215
+ * chip's `max-w-40` cap, over chip + gap + control), and rounded outward: a
216
+ * bound that understates is a bound that lets the cluster lap over a card.
217
+ */
218
+ chip: {
219
+ width: 80,
220
+ height: 20
221
+ }
222
+ };
223
+ /**
224
+ * How many cards a cluster may step past before the nudge is abandoned.
225
+ *
226
+ * A BOX COUNT rather than a distance, because what has to be cleared scales with
227
+ * the layout: a node is 292 units across, so in "TB" — where that width is the
228
+ * cross axis — moving a control off the card it sits on costs over 160, while
229
+ * the same node in "LR" costs about 50. Any fixed distance is therefore either
230
+ * too small to clear one standard card in one orientation, or too loose to mean
231
+ * anything in the other. Counting cards says what was actually meant: clearing
232
+ * the card you are on is the whole point, the next one is its neighbour in a
233
+ * stacked layer and is cheap, and past that the cluster is crossing a dense
234
+ * fan-out and would end up nowhere near the edge it belongs to — where leaving
235
+ * it overlapping, still clickable through the raised label layer, is the better
236
+ * of two bad answers.
237
+ */
238
+ const INSERT_MAX_CARDS_CROSSED = 2;
239
+ /**
240
+ * The CROSS-axis shift that moves a point off whatever node boxes it lands in:
241
+ * 0 when it is already clear, and null when it is on a card that cannot be left
242
+ * within the bound. A caller has to be able to tell those two apart — only the
243
+ * second has to give up its pointer events.
244
+ *
245
+ * An edge spanning more than one layer runs THROUGH the layer it skips, so its
246
+ * midpoint — where a control would otherwise sit — is inside an unrelated card.
247
+ * The reserved corridor only ever clears an adjacent-layer edge, because that is
248
+ * the only gap the layout can widen. Nudging along the cross axis keeps the
249
+ * control beside its own edge and off the card it was covering.
250
+ *
251
+ * Both directions are walked, so a stack of cards in the skipped layer is
252
+ * escaped rather than jumped into; the smaller shift wins, and a point that
253
+ * cannot be cleared inside {@link INSERT_MAX_CARDS_CROSSED} keeps its place, and
254
+ * says so by answering null rather than an offset.
255
+ */
256
+ function clearOfNodeBoxes(point, boxes, direction, half) {
257
+ const isLR = direction === "LR";
258
+ const base = isLR ? point.y : point.x;
259
+ const main = isLR ? point.x : point.y;
260
+ const padMain = isLR ? half.width : half.height;
261
+ const padCross = isLR ? half.height : half.width;
262
+ const column = boxes.filter((b) => {
263
+ const lo = (isLR ? b.x : b.y) - padMain;
264
+ const size = (isLR ? b.width : b.height) + padMain * 2;
265
+ return main >= lo && main <= lo + size;
266
+ });
267
+ if (column.length === 0) return 0;
268
+ const walk = (sign) => {
269
+ let at = base;
270
+ for (let crossed = 0; crossed <= INSERT_MAX_CARDS_CROSSED; crossed += 1) {
271
+ const hit = column.find((b) => {
272
+ const lo = (isLR ? b.y : b.x) - padCross;
273
+ const size = (isLR ? b.height : b.width) + padCross * 2;
274
+ return at >= lo && at <= lo + size;
275
+ });
276
+ if (!hit) return at - base;
277
+ if (crossed === INSERT_MAX_CARDS_CROSSED) return null;
278
+ const lo = isLR ? hit.y : hit.x;
279
+ const size = isLR ? hit.height : hit.width;
280
+ at = sign > 0 ? lo + size + padCross + INSERT_CLEARANCE : lo - padCross - INSERT_CLEARANCE;
281
+ }
282
+ return null;
283
+ };
284
+ const up = walk(-1);
285
+ const down = walk(1);
286
+ if (up === null && down === null) return null;
287
+ if (up === null) return down;
288
+ if (down === null) return up;
289
+ return Math.abs(up) <= Math.abs(down) ? up : down;
290
+ }
291
+ //#endregion
292
+ //#region src/workflows/node-output.tsx
293
+ /** Failure text, in the semantic danger token — which carries a light AND a dark
294
+ * value. A palette shade (`text-red-400`) only ever reads well in one theme. */
295
+ const ERROR_TEXT = { color: "var(--surface-danger-text)" };
296
+ /** Max key/value pairs a shallow-object output renders before it reports
297
+ * `truncated` — the card only has room for a couple rows anyway. */
298
+ const MAX_JSON_ENTRIES = 6;
299
+ /**
300
+ * A line that is pure structure — a code fence (with or without a language tag), a
301
+ * table's rule row, a thematic break — and so carries no words to keep.
302
+ *
303
+ * Every test is a SINGLE quantified character class: unambiguous, and linear in the
304
+ * length of the line. Written as one pattern (`[\s|:-]+\|[\s|:-]*`) the two
305
+ * quantifiers would compete for the same `|`, which is the shape that backtracks.
306
+ * Recognize the charset in one pass instead, then ask the cheap questions — is
307
+ * there a pipe? a dash? — with plain string checks.
308
+ */
309
+ function isStructureOnly(line) {
310
+ if (/^(```|~~~)/.test(line)) return true;
311
+ const isBreak = /^([-*_])\1{2,}$/.test(line);
312
+ if (/^[\s|:-]+$/.test(line) && line.includes("|")) return line.includes("-");
313
+ return isBreak;
314
+ }
315
+ /**
316
+ * Flatten an agent's markdown answer into the sentence a person would read out
317
+ * of it. On a two-line card preview the syntax IS the noise: a `## Heading` mid
318
+ * paragraph, a `**bold**` run and a bullet's `-` all survive the clamp while the
319
+ * words that carry the meaning get pushed past it. Stripping the markup (never
320
+ * the words) is what turns the preview back into prose — the full text, markup
321
+ * and all, is still rendered by the node's detail view.
322
+ *
323
+ * Structure-only lines (a fence, a table rule) are dropped; everything else keeps
324
+ * its text and is joined into one flowing line.
325
+ */
326
+ function condenseText(text) {
327
+ const isListItem = (line) => /^\s*([-*+]\s|\d+[.)]\s)/.test(line);
328
+ return text.split("\n").map((line) => ({
329
+ item: isListItem(line),
330
+ text: line.replace(/^\s{0,3}#{1,6}\s+/, "").replace(/^\s{0,3}>\s?/, "").replace(/^\s*[-*+]\s+(\[[ xX]\]\s+)?/, "").replace(/^\s*\d+[.)]\s+/, "").trim()
331
+ })).filter(({ text: line }) => line !== "" && !isStructureOnly(line)).map(({ item, text: line }, i) => i > 0 && item ? `· ${line}` : line).join(" ").replace(/(?<=^|\s)(\*\*|~~)(?!\s)(.+?)(?<!\s)\1(?=[\s.,:;!?)\]}"']|$)/g, "$2").replace(/(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim();
332
+ }
333
+ /** A single JSON value as a compact one-line string for the key/value render.
334
+ * Scalars render verbatim; a nested object/array collapses to a shape marker so
335
+ * one row never balloons into a wall of text. */
336
+ function scalar(value) {
337
+ if (value === null) return "null";
338
+ if (typeof value === "string") return value;
339
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
340
+ if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
341
+ if (typeof value === "object") return "{…}";
342
+ return "";
343
+ }
344
+ /**
345
+ * Recover the shape of an output preview string. Only text that *begins* like
346
+ * JSON (`{`/`[`) is parse-attempted, so ordinary prose starting with a brace-free
347
+ * sentence is never mis-parsed. A begins-like-JSON string that fails to parse
348
+ * (the host clamped it mid-value) still renders as `code`, not prose, so it keeps
349
+ * its monospace/data affordance.
350
+ *
351
+ * `condenseMarkdown` says the prose IS markdown and may be flattened for the
352
+ * preview ({@link condenseText}). It is OFF by default, and the caller must only
353
+ * turn it on for text an agent actually authored as markdown. Applied to anything
354
+ * else it does not merely look wrong — it silently REWRITES the text: a stack
355
+ * trace's `__init__.py` loses its underscores, and a shell glob `src/**` pairs
356
+ * with a later `**` and both vanish. An error message is the one string a person
357
+ * reads to find out what broke; it must reach them exactly as it was thrown.
358
+ */
359
+ function classifyOutput(preview, condenseMarkdown = false) {
360
+ let trimmed = preview?.trim() ?? "";
361
+ trimmed = trimmed.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, "").replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "").trimEnd();
362
+ if (trimmed === "") return { kind: "empty" };
363
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
364
+ const parsed = JSON.parse(trimmed);
365
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
366
+ const all = Object.entries(parsed);
367
+ const entries = all.slice(0, MAX_JSON_ENTRIES).map(([k, v]) => [k, scalar(v)]);
368
+ if (entries.length > 0) return {
369
+ kind: "json",
370
+ entries,
371
+ truncated: all.length > entries.length
372
+ };
373
+ }
374
+ return {
375
+ kind: "code",
376
+ text: trimmed
377
+ };
378
+ } catch {
379
+ return {
380
+ kind: "code",
381
+ text: trimmed
382
+ };
383
+ }
384
+ if (!condenseMarkdown) return {
385
+ kind: "text",
386
+ text: trimmed
387
+ };
388
+ const condensed = condenseText(trimmed);
389
+ return condensed === "" ? { kind: "empty" } : {
390
+ kind: "text",
391
+ text: condensed
392
+ };
393
+ }
394
+ /** Line-clamp utility per row budget, so `code`/`text` shapes honor `rows` the
395
+ * same way the JSON branch does (rather than always clamping to two). Covers the
396
+ * full range a card can reserve; an unsupported count falls back to the two-line
397
+ * clamp, which renders FEWER lines than the caller asked for — so a card whose
398
+ * reservation outgrows this map shows a short body inside a tall well rather
399
+ * than erroring. Extend the map when a design reserves more.
400
+ * Class names are literal so the Tailwind scanner emits each utility. */
401
+ const CLAMP_BY_ROWS = {
402
+ 1: "truncate",
403
+ 2: "line-clamp-2",
404
+ 3: "line-clamp-3",
405
+ 4: "line-clamp-4",
406
+ 5: "line-clamp-5",
407
+ 6: "line-clamp-6"
408
+ };
409
+ /**
410
+ * Render a classified output preview's CONTENT (no surrounding frame — each node
411
+ * design supplies its own container/label). `rows` caps the visible lines so the
412
+ * body stays within a card's reserved output space; `tone="error"` colors it for
413
+ * a failure message.
414
+ *
415
+ * `rows="none"` renders the body UNCLAMPED, for a container that scrolls or is
416
+ * sized by its content — a drawer, a detail panel, a selection overlay. It is a
417
+ * word rather than a large number because there is no number that means "do not
418
+ * clamp": a count outside {@link CLAMP_BY_ROWS} falls back to two lines, so
419
+ * `rows={99}` asks for everything and silently shows the least.
420
+ */
421
+ function NodeOutputBody({ shape, rows = 2, tone = "default" }) {
422
+ const unclamped = rows === "none";
423
+ const clampClass = unclamped ? "" : CLAMP_BY_ROWS[rows] ?? "line-clamp-2";
424
+ const codeWrap = rows === 1 ? "" : "whitespace-pre-wrap break-all";
425
+ if (shape.kind === "empty") return null;
426
+ if (shape.kind === "json") {
427
+ const overflow = unclamped ? shape.truncated : shape.truncated || shape.entries.length > rows;
428
+ const visible = unclamped ? shape.entries : shape.entries.slice(0, overflow ? Math.max(0, rows - 1) : rows);
429
+ const isError = tone === "error";
430
+ const keyStyle = isError ? ERROR_TEXT : void 0;
431
+ const keyClass = isError ? "" : "text-muted-foreground";
432
+ const valueClass = isError ? "" : "text-foreground";
433
+ return /* @__PURE__ */ jsxs("dl", {
434
+ className: "space-y-0.5 font-mono text-[10.5px] leading-snug",
435
+ children: [visible.map(([key, value]) => /* @__PURE__ */ jsxs("div", {
436
+ className: "flex gap-1.5",
437
+ children: [/* @__PURE__ */ jsx("dt", {
438
+ className: `max-w-[45%] shrink-0 truncate ${keyClass}`,
439
+ style: keyStyle,
440
+ title: key,
441
+ children: key
442
+ }), /* @__PURE__ */ jsx("dd", {
443
+ className: `min-w-0 flex-1 truncate ${valueClass}`,
444
+ style: isError ? ERROR_TEXT : void 0,
445
+ title: value,
446
+ children: value
447
+ })]
448
+ }, key)), overflow && /* @__PURE__ */ jsx("div", {
449
+ className: `text-[10px] ${keyClass}`,
450
+ style: keyStyle,
451
+ children: "…"
452
+ })]
453
+ });
454
+ }
455
+ if (shape.kind === "code") return /* @__PURE__ */ jsx("pre", {
456
+ className: `${clampClass} ${codeWrap} font-mono text-[10.5px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
457
+ style: tone === "error" ? ERROR_TEXT : void 0,
458
+ title: shape.text,
459
+ children: shape.text
460
+ });
461
+ return /* @__PURE__ */ jsx("p", {
462
+ className: `${clampClass} text-[11px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
463
+ style: tone === "error" ? ERROR_TEXT : void 0,
464
+ title: shape.text,
465
+ children: shape.text
466
+ });
467
+ }
468
+ //#endregion
469
+ //#region src/workflows/node-ui.tsx
470
+ /**
471
+ * Shared presentational atoms for a workflow graph node — the type glyph, the
472
+ * status color/label/badge maps, and the edge coloring. One source of truth so
473
+ * the node card and the edges that point at it read as one system.
474
+ *
475
+ * Colors resolve to the SEMANTIC surface tokens (`--surface-success-text`,
476
+ * `--surface-danger-text`, …), which carry a light AND a dark value tuned for
477
+ * contrast against the card. A literal palette hex (`#22c55e`) or a stock
478
+ * Tailwind shade (`text-red-400`) carries ONE value, so it can only ever hold its
479
+ * contrast in one of the two themes — never use one for a status color.
480
+ *
481
+ * Inline `style` colors here use the RAW brand tokens (`hsl(var(--primary))`,
482
+ * `var(--surface-…)`) — NOT the `--color-*` @theme aliases, which are undefined
483
+ * in a consumer that only imports `tokens.css` (e.g. platform-web), where a
484
+ * `var(--color-*)` would silently resolve to nothing. See the edge-color test.
485
+ */
486
+ /** Track color behind a progress bar — a faint wash of the muted token. */
487
+ const MUTED_TRACK = "color-mix(in srgb, hsl(var(--muted-foreground)) 22%, transparent)";
488
+ const TONE_ACCENT = {
489
+ trigger: "hsl(var(--primary))",
490
+ structural: "var(--surface-warning-text)",
491
+ action: "hsl(var(--muted-foreground))"
492
+ };
493
+ const STATUS_QUEUED = "hsl(var(--muted-foreground))";
494
+ const STATUS_COLOR = {
495
+ queued: STATUS_QUEUED,
496
+ running: "hsl(var(--primary))",
497
+ waiting: "var(--surface-warning-text)",
498
+ succeeded: "var(--surface-success-text)",
499
+ failed: "var(--surface-danger-text)"
500
+ };
501
+ /** An edge is colored by the status of the node it points AT, so the run's
502
+ * "front" lights up. Neutral for a not-yet-reached (queued) target or the
503
+ * static definition view (`undefined`). */
504
+ function edgeColor(status) {
505
+ return status ? STATUS_COLOR[status] : STATUS_QUEUED;
506
+ }
507
+ const STATUS_LABEL = {
508
+ queued: "Queued",
509
+ running: "Running",
510
+ waiting: "Waiting on you",
511
+ succeeded: "Done",
512
+ failed: "Failed"
513
+ };
514
+ /** Status pill styling — a tinted well in the status color. Built from the
515
+ * semantic surface trio (bg/border/text) so the pill keeps its contrast in both
516
+ * themes rather than washing out in one of them. */
517
+ const STATUS_PILL = {
518
+ queued: {
519
+ background: "color-mix(in srgb, hsl(var(--muted-foreground)) 12%, transparent)",
520
+ color: "hsl(var(--muted-foreground))",
521
+ borderColor: "color-mix(in srgb, hsl(var(--muted-foreground)) 28%, transparent)"
522
+ },
523
+ running: {
524
+ background: "color-mix(in srgb, hsl(var(--primary)) 14%, transparent)",
525
+ color: "hsl(var(--primary))",
526
+ borderColor: "color-mix(in srgb, hsl(var(--primary)) 40%, transparent)"
527
+ },
528
+ waiting: {
529
+ background: "var(--surface-warning-bg)",
530
+ color: "var(--surface-warning-text)",
531
+ borderColor: "var(--surface-warning-border)"
532
+ },
533
+ succeeded: {
534
+ background: "var(--surface-success-bg)",
535
+ color: "var(--surface-success-text)",
536
+ borderColor: "var(--surface-success-border)"
537
+ },
538
+ failed: {
539
+ background: "var(--surface-danger-bg)",
540
+ color: "var(--surface-danger-text)",
541
+ borderColor: "var(--surface-danger-border)"
542
+ }
543
+ };
544
+ /** The card's border + ring once a node has live run state, so the running node
545
+ * and the terminal ones read at a glance. Returned as inline style (not classes)
546
+ * because the status colors are semantic tokens, not palette shades. */
547
+ function statusBorder(status) {
548
+ const color = STATUS_COLOR[status];
549
+ switch (status) {
550
+ case "running":
551
+ case "waiting": return {
552
+ borderColor: color,
553
+ boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 45%, transparent), 0 0 24px -6px ${color}`
554
+ };
555
+ case "queued": return { borderColor: "hsl(var(--border))" };
556
+ default: return {
557
+ borderColor: color,
558
+ boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
559
+ };
560
+ }
561
+ }
562
+ /** Determinate progress fraction for a node's status: queued reads near-empty,
563
+ * running and waiting a partial (the run reached this node but has not finished
564
+ * it), terminal full. Shared so every progress treatment (bar, footer, fill) maps
565
+ * status → fill identically. A `waiting` bar is deliberately NOT animated by its
566
+ * callers: the run is stopped there, and a moving bar would say otherwise. */
567
+ function progressFill(status) {
568
+ return status === "succeeded" || status === "failed" ? "100%" : status === "running" || status === "waiting" ? "58%" : "6%";
569
+ }
570
+ const KIND_ICON = {
571
+ schedule: Clock,
572
+ provider_event: Webhook,
573
+ webhook: Webhook,
574
+ trigger: Zap,
575
+ "agent.run": Sparkles,
576
+ "integration.invoke": Cable,
577
+ notify: Bell,
578
+ parallel: GitBranch,
579
+ foreach: Repeat,
580
+ decision: UserCheck,
581
+ "sandbox.spawn": Box,
582
+ "sandbox.snapshot": Camera,
583
+ "script.run": Code,
584
+ "trace.analyze": ScanSearch
585
+ };
586
+ /** An authoring problem's colours — the same semantic surface trio the run
587
+ * statuses use, so a broken step and a failed one are recognisably the same
588
+ * family of "something is wrong here" without being the same signal. */
589
+ const PROBLEM_SURFACE_BY_SEVERITY = {
590
+ error: {
591
+ background: "var(--surface-danger-bg)",
592
+ color: "var(--surface-danger-text)",
593
+ borderColor: "var(--surface-danger-border)"
594
+ },
595
+ warning: {
596
+ background: "var(--surface-warning-bg)",
597
+ color: "var(--surface-warning-text)",
598
+ borderColor: "var(--surface-warning-border)"
599
+ }
600
+ };
601
+ /**
602
+ * The colours a severity is drawn in. A lookup rather than the table itself, so
603
+ * a severity this library has no entry for cannot throw its way out of a render
604
+ * — the same resilience `problemSentence` and `worstSeverity` already give it,
605
+ * and for the same reason: the package is consumed from JavaScript, and one
606
+ * malformed problem must not take the canvas down. An unrecognised severity is
607
+ * drawn as an error, matching how it is named and ranked.
608
+ */
609
+ function problemSurface(severity) {
610
+ return PROBLEM_SURFACE_BY_SEVERITY[severity] ?? PROBLEM_SURFACE_BY_SEVERITY.error;
611
+ }
612
+ /** A node's border once it carries an authoring problem. Applied only where the
613
+ * node has no RUN status to state — a run's border is the more urgent reading
614
+ * of the same edge of the same box, and a definition being edited has no run. */
615
+ function problemBorder(severity) {
616
+ const color = problemSurface(severity).color;
617
+ return {
618
+ borderColor: color,
619
+ boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
620
+ };
621
+ }
622
+ /** How each severity is named where a colour cannot be seen. */
623
+ const SEVERITY_WORD = {
624
+ error: "Error",
625
+ warning: "Warning"
626
+ };
627
+ /**
628
+ * One problem as a reader receives it: WHICH KIND it is, then what it says.
629
+ *
630
+ * The kind has to be in the words. A mark shows one aggregate severity — as a
631
+ * colour and a glyph, neither of which a screen reader receives — so an anchor
632
+ * carrying an error and a warning together would otherwise offer no way to tell
633
+ * which of the two is the one blocking the compile.
634
+ *
635
+ * NEWLINES are the only thing rewritten: each line is trimmed and the lines are
636
+ * run together, so a multi-line host message cannot break the one-problem-per-
637
+ * line reading below. Everything else survives — including internal spacing and
638
+ * the trailing punctuation a diagnostic may be ABOUT ("unexpected ;") — because
639
+ * the canvas has to say what the host's own problem list says. Separators are
640
+ * handled at the join instead ({@link joinSentences}).
641
+ *
642
+ * A problem with no message at all is named by its severity alone, rather than
643
+ * by a colon with nothing after it: nothing in {@link WfProblem} requires the
644
+ * text to be non-empty, and "this step has an error" is still worth saying.
645
+ */
646
+ function problemSentence(problem) {
647
+ const message = (typeof problem.message === "string" ? problem.message : "").split("\n").map((line) => line.trim()).filter(Boolean).join(" ");
648
+ const word = SEVERITY_WORD[problem.severity] ?? SEVERITY_WORD.error;
649
+ return message ? `${word}: ${message}` : word;
650
+ }
651
+ /** Run sentences together for a listener, adding a full stop only where the text
652
+ * does not already end in one — so "Why?" is followed by a space rather than
653
+ * "Why?.", and a message ending in a semicolon keeps it. Empty in, empty out:
654
+ * `reduce` with no seed throws on an empty list, and this is reachable from any
655
+ * caller holding an empty problem set. */
656
+ function joinSentences(parts) {
657
+ return parts.reduce((joined, part) => joined ? `${joined}${/[.!?;,]$/.test(joined) ? "" : "."} ${part}` : part, "");
658
+ }
659
+ /** Every problem on one anchor, as the tooltip text a marker hangs on. One per
660
+ * line, so a step with three faults lists three rather than concatenating them
661
+ * into a sentence nobody can parse. */
662
+ function problemTitle(problems) {
663
+ return problems.map(problemSentence).join("\n");
664
+ }
665
+ /**
666
+ * Every message on one anchor, as the text a reader who cannot hover receives.
667
+ * The visible mark has room for a glyph and a count, so the messages themselves
668
+ * would otherwise live only in a `title` — which a pointer opens and a keyboard,
669
+ * a screen reader and a touch device do not. Rendering them visually hidden puts
670
+ * the same words in the accessibility tree, where they become the mark's own
671
+ * accessible name.
672
+ */
673
+ function ProblemMessages({ problems }) {
674
+ if (problems.length === 0) return null;
675
+ return /* @__PURE__ */ jsx("span", {
676
+ className: "sr-only",
677
+ children: problems.length === 1 ? problemSentence(problems[0]) : `${problems.length} problems. ${joinSentences(problems.map(problemSentence))}`
678
+ });
679
+ }
680
+ /**
681
+ * The mark a node wears when the draft it is drawn from has a problem on it: a
682
+ * warning glyph, with a count once there is more than one. It is a READOUT, not
683
+ * a control — the node's own click already opens whatever the host shows for it,
684
+ * and a second target inside the card would compete with that.
685
+ *
686
+ * It carries every message twice over: on `title`, where a pointer finds it, and
687
+ * as visually hidden text, which is what the accessibility tree reads. That is
688
+ * what makes the mark worth more than the border tint — the canvas says WHICH
689
+ * step is broken at a glance and WHY without a trip to the problem list, however
690
+ * the reader is driving it.
691
+ */
692
+ function ProblemMarker({ problems, className = "" }) {
693
+ const severity = worstSeverity(problems) ?? "error";
694
+ return /* @__PURE__ */ jsxs("span", {
695
+ "data-testid": "wf-node-problem",
696
+ "data-severity": severity,
697
+ className: `flex shrink-0 items-center gap-0.5 rounded-full border px-1 py-[1px] font-medium text-[10px] leading-none ${className}`,
698
+ style: problemSurface(severity),
699
+ children: [/* @__PURE__ */ jsxs("span", {
700
+ "aria-hidden": true,
701
+ className: "flex items-center gap-0.5",
702
+ title: problemTitle(problems),
703
+ children: [/* @__PURE__ */ jsx(AlertTriangle, { size: 9 }), problems.length > 1 && /* @__PURE__ */ jsx("span", { children: problems.length })]
704
+ }), /* @__PURE__ */ jsx(ProblemMessages, { problems })]
705
+ });
706
+ }
707
+ /** The status pill in a card's header — the one place the run state is spelled
708
+ * out in words. */
709
+ function StatusPill({ status }) {
710
+ return /* @__PURE__ */ jsx("span", {
711
+ className: "shrink-0 whitespace-nowrap rounded-full border px-2 py-[1px] font-medium text-[10px]",
712
+ style: STATUS_PILL[status],
713
+ children: STATUS_LABEL[status]
714
+ });
715
+ }
716
+ /**
717
+ * The run status FOOTER: a progress bar (queued = near-empty, running = pulsing
718
+ * partial, terminal = full) over a caption row. Pinned to the card's BOTTOM via
719
+ * `mt-auto` so it lands at the same place on every node regardless of how much
720
+ * content sits above it — unlike an inline strip, whose height drifts. The
721
+ * caption reads agent rounds on the left and elapsed on the right; status itself
722
+ * is carried by the bar color + the header pill, so it is NOT restated here. The
723
+ * caption row always renders (even when empty) so the footer band keeps a
724
+ * constant height across nodes.
725
+ */
726
+ function StatusFooter({ status, rounds, cost, tokens, elapsed }) {
727
+ const caption = [
728
+ rounds !== void 0 ? `${rounds} round${rounds === 1 ? "" : "s"}` : void 0,
729
+ cost,
730
+ tokens
731
+ ].filter(Boolean).join(" · ");
732
+ return /* @__PURE__ */ jsxs("div", {
733
+ className: "wf-node-body-in mt-auto border-border border-t",
734
+ children: [/* @__PURE__ */ jsx("div", {
735
+ className: "h-1 w-full overflow-hidden",
736
+ style: { background: MUTED_TRACK },
737
+ children: /* @__PURE__ */ jsx("div", {
738
+ "data-testid": "wf-node-progress",
739
+ className: `h-full ${status === "running" ? "animate-pulse" : ""}`,
740
+ ...status === "running" ? { "data-motion": "essential" } : {},
741
+ style: {
742
+ width: progressFill(status),
743
+ background: STATUS_COLOR[status]
744
+ }
745
+ })
746
+ }), /* @__PURE__ */ jsxs("div", {
747
+ className: "flex items-center justify-between gap-2 px-3.5 py-1 text-[10px] text-muted-foreground leading-[15px]",
748
+ children: [/* @__PURE__ */ jsx("span", {
749
+ className: "min-h-[15px] truncate tabular-nums",
750
+ children: caption
751
+ }), /* @__PURE__ */ jsx("span", {
752
+ className: "min-h-[15px] shrink-0 tabular-nums",
753
+ children: elapsed ?? ""
754
+ })]
755
+ })]
756
+ });
757
+ }
758
+ /**
759
+ * What an expanded card says where its output WOULD be, when it has none. The
760
+ * card is sized for the output it may yet have (the layout is computed once,
761
+ * before any run state, so it can never reflow mid-run) — leaving that space
762
+ * blank reads as a broken card rather than as a step with nothing to report. A
763
+ * running node is the exception: its output may still be on its way, so it waits
764
+ * quietly rather than claiming there is none.
765
+ */
766
+ function emptySlotLabel(status) {
767
+ switch (status) {
768
+ case "queued": return "Not run yet";
769
+ case "succeeded": return "No output";
770
+ case "failed": return "No error reported";
771
+ default: return;
772
+ }
773
+ }
774
+ /**
775
+ * A node's MARK, centered in a tile the caller sizes: the provider's brand logo
776
+ * when the step runs against a connector — the way n8n makes a node recognizable
777
+ * before a word of it is read — else the kind's glyph, tinted by tone. Both
778
+ * densities render the same mark at different sizes, so a node looks like itself
779
+ * whether it's an icon tile or a full card.
780
+ */
781
+ function NodeMark({ kind, provider, model, accent, tile }) {
782
+ const Icon = kind && KIND_ICON[kind] || Circle;
783
+ const brand = !provider && model ? modelBrandFor(model) : null;
784
+ const mark = Math.round(tile * (provider ? .58 : .5));
785
+ return /* @__PURE__ */ jsx("span", {
786
+ "aria-hidden": "true",
787
+ className: "flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border",
788
+ style: {
789
+ width: tile,
790
+ height: tile,
791
+ background: `color-mix(in srgb, ${accent} 10%, hsl(var(--card)))`
792
+ },
793
+ children: provider ? /* @__PURE__ */ jsx(ProviderIcon, {
794
+ id: provider,
795
+ displayName: providerLabel(provider),
796
+ size: mark,
797
+ className: "rounded-[3px]"
798
+ }) : brand ? /* @__PURE__ */ jsx(ModelBrandStack, {
799
+ identity: brand,
800
+ size: brand.combined || tile >= 38 ? "md" : "sm"
801
+ }) : /* @__PURE__ */ jsx(Icon, {
802
+ size: mark,
803
+ strokeWidth: 1.75,
804
+ style: { color: accent }
805
+ })
806
+ });
807
+ }
808
+ //#endregion
809
+ //#region src/workflows/WorkflowGraph.tsx
810
+ /**
811
+ * n8n-style node-graph view of a workflow, rendered from its YAML definition.
812
+ * Used full-size on the workflow detail page and as a compact, non-interactive
813
+ * preview on assistant proposal cards. Both share one graph model + node
814
+ * component; only interactivity and sizing differ by `variant`.
815
+ */
816
+ /**
817
+ * How much of the canvas a fit leaves as margin.
818
+ *
819
+ * A PIXEL gutter, not a fraction. Padding exists to keep the graph out from
820
+ * under the two overlays — the toolbar at top-left and the zoom controls at
821
+ * bottom-right — and those are a fixed size whatever the canvas is. Expressed
822
+ * as the old scale factor (0.16, i.e. fit into 1/1.16 of the frame) it instead
823
+ * grew with the panel, so the bigger the canvas the more of it a fit refused to
824
+ * use: measured on a 15-node pipeline in an 854×480 panel, a fit reached only
825
+ * 91% of the zoom that would still have fitted, and every pixel of that gap is
826
+ * legibility given away on exactly the graphs that need it most.
827
+ *
828
+ * 12px, because the gutter is breathing room and nothing more. The two overlays
829
+ * sit in OPPOSITE corners (toolbar top-left, zoom controls bottom-right) and are
830
+ * translucent chrome the canvas is expected to run under — reserving a band wide
831
+ * enough to clear them on all four sides would cost more zoom than the old
832
+ * scale factor did, on the short axis where a wide pipeline is already tightest.
833
+ */
834
+ const FIT_VIEW = { padding: "12px" };
835
+ /**
836
+ * Zoom CEILING for a fit at the given density. Full cards at 1 are already
837
+ * their designed size — zooming a two-node graph past that just blows the cards
838
+ * up to fill the canvas. Compact tiles are small BY DESIGN, so fitting them
839
+ * into the same canvas legitimately zooms past 1; capping them there strands a
840
+ * short compact graph as specks in empty space.
841
+ */
842
+ function fitZoomCeiling(compact) {
843
+ return compact ? 1.5 : 1;
844
+ }
845
+ /**
846
+ * Zoom FLOOR for a fit at the given density — the point below which the graph
847
+ * stops shrinking to fit and becomes pannable instead.
848
+ *
849
+ * It depends on density for the same reason the ceiling does, because what a
850
+ * floor BUYS depends on what the node is made of. A compact node is a logo: at
851
+ * 0.55 the tile is still 42px and every step is recognisable at a glance, so
852
+ * refusing to shrink past it keeps the graph worth looking at. An expanded card
853
+ * is made of TEXT, and its text stops being readable around 0.75 — well before
854
+ * any zoom that would fit a real pipeline. Holding those cards at the compact
855
+ * floor therefore buys nothing legible and costs the right-hand column, which
856
+ * gets sliced by the canvas edge. Letting them shrink shows the whole shape
857
+ * instead, and reading one card is a click (or a zoom) away either way.
858
+ */
859
+ function fitZoomFloor(compact) {
860
+ return compact ? .55 : .35;
861
+ }
862
+ /**
863
+ * The rectangle the LAID-OUT nodes occupy, in flow coordinates.
864
+ *
865
+ * Read straight off the layouter's own position + size rather than through
866
+ * React Flow's `getNodesBounds`. Both of that helper's forms prefer a node's
867
+ * MEASURED box — the standalone one falls back to it, and the `useReactFlow`
868
+ * one resolves every node through the store's `nodeLookup`, which holds nothing
869
+ * else — and measurement (a ResizeObserver) lags a relayout by a frame or more.
870
+ * Framing a fresh layout through either therefore frames the PREVIOUS layout's
871
+ * sizes as often as the new one, which is the same race that keeps `fitView`
872
+ * out of the reframe below. Every structural node carries an explicit
873
+ * width/height, and for exactly this layout that is the authority, so there is
874
+ * nothing to measure and no lookup to consult.
875
+ */
876
+ function layoutBounds(nodes) {
877
+ if (nodes.length === 0) return {
878
+ x: 0,
879
+ y: 0,
880
+ width: 0,
881
+ height: 0
882
+ };
883
+ let minX = Number.POSITIVE_INFINITY;
884
+ let minY = Number.POSITIVE_INFINITY;
885
+ let maxX = Number.NEGATIVE_INFINITY;
886
+ let maxY = Number.NEGATIVE_INFINITY;
887
+ for (const node of nodes) {
888
+ minX = Math.min(minX, node.position.x);
889
+ minY = Math.min(minY, node.position.y);
890
+ maxX = Math.max(maxX, node.position.x + (node.width ?? 0));
891
+ maxY = Math.max(maxY, node.position.y + (node.height ?? 0));
892
+ }
893
+ return {
894
+ x: minX,
895
+ y: minY,
896
+ width: maxX - minX,
897
+ height: maxY - minY
898
+ };
899
+ }
900
+ /**
901
+ * The camera that frames `bounds` inside a `width`×`height` canvas.
902
+ *
903
+ * Whenever the zoom FLOOR clamps the fit (see FIT_VIEW), the graph is
904
+ * deliberately larger than the canvas — it is meant to be panned. React Flow
905
+ * CENTERS what it cannot fit, which splits the overflow across both ends at
906
+ * once: the trigger leaves the leading edge at the same moment the last step
907
+ * leaves the trailing one, so the reader sees neither terminus and has nothing
908
+ * to say which way the graph continues. Anchoring the leading edge instead puts
909
+ * the entry point on screen and leaves every hidden step in ONE direction — the
910
+ * direction the graph already reads in — so what is off-canvas is where panning
911
+ * naturally goes.
912
+ *
913
+ * The anchor is the canvas edge, not the padding inset: padding is slack, and a
914
+ * clamped fit is the case where there is none to give. It is also expressed as
915
+ * a clamp rather than a branch on "does it fit" — a fit the floor did NOT clamp
916
+ * is already centered with slack on both sides, so `max` returns it untouched,
917
+ * and nothing here has to re-derive React Flow's own reading of `padding`.
918
+ */
919
+ function framingViewport(bounds, width, height, compact) {
920
+ const fit = getViewportForBounds(bounds, width, height, fitZoomFloor(compact), fitZoomCeiling(compact), FIT_VIEW.padding);
921
+ /** Slide `offset` just far enough that the bounds' leading edge is on canvas. */
922
+ const anchored = (offset, origin) => offset + Math.max(0, -(offset + origin * fit.zoom));
923
+ return {
924
+ zoom: fit.zoom,
925
+ x: anchored(fit.x, bounds.x),
926
+ y: anchored(fit.y, bounds.y)
927
+ };
928
+ }
929
+ /**
930
+ * When the RENDERED density flips inside the layout tween, as a fraction of
931
+ * LAYOUT_TRANSITION_MS. The box morphs first and the incoming content fades in
932
+ * mid-motion (see `.wf-node-body-in`) — flipping while everything is already
933
+ * moving is what keeps the swap from reading as a pop. Direction matters: the
934
+ * two densities' shells are closest in size near the COMPACT geometry, so
935
+ * growing (→ expanded) flips just after the box starts to grow, and shrinking
936
+ * (→ compact) flips once the card has nearly collapsed.
937
+ */
938
+ const DENSITY_FLIP_AT = {
939
+ grow: .12,
940
+ shrink: .75
941
+ };
942
+ /** Motion is opt-OUT, so it is only used where the reader's preference can be
943
+ * read and says nothing against it. Somewhere without `matchMedia` cannot say
944
+ * a reader tolerates movement, so it gets none. */
945
+ function motionAllowed() {
946
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === false;
947
+ }
948
+ const lerp = (a, b, t) => a + (b - a) * t;
949
+ /** The layout tween's easing — gentle into and out of the motion, so neither
950
+ * end of the morph reads as a snap. */
951
+ const easeInOutCubic = (t) => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
952
+ /** Tracks the app's dark/light class so React Flow's chrome (edges, controls,
953
+ * background) themes with the rest of the app. */
954
+ function useColorMode() {
955
+ const [mode, setMode] = useState(() => typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "light");
956
+ useEffect(() => {
957
+ const el = document.documentElement;
958
+ const update = () => setMode(el.classList.contains("dark") ? "dark" : "light");
959
+ update();
960
+ const observer = new MutationObserver(update);
961
+ observer.observe(el, {
962
+ attributes: true,
963
+ attributeFilter: ["class"]
964
+ });
965
+ return () => observer.disconnect();
966
+ }, []);
967
+ return mode;
968
+ }
969
+ /** Flow direction for the current graph, so the node component can place its
970
+ * edge handles on the correct sides (edges enter the leading edge and leave the
971
+ * trailing edge) without threading a prop through React Flow's node renderer. */
972
+ const DirectionContext = createContext("LR");
973
+ /** Compact (collapsed) density for the current graph — read by the node so it
974
+ * renders the icon-tile summary instead of the full card. Set by the density
975
+ * toggle (and forced on for the proposal-card preview). */
976
+ const DensityContext = createContext(false);
977
+ /**
978
+ * The node the HOST considers selected, if any. Carried by context rather than
979
+ * merged into node data because selection is not a property of the workflow:
980
+ * writing it into `data` would rebuild the node objects on every selection
981
+ * change, which is the one thing the run-state merge works hard to avoid.
982
+ */
983
+ const SelectedNodeContext = createContext(void 0);
984
+ /**
985
+ * Whether the GRAPH is showing a run — read by the node so its card matches what
986
+ * the layout reserved for it.
987
+ *
988
+ * `nodeHeight` reserves the run bands for every action the moment the graph has
989
+ * any run state, because the layout is computed once and cannot revisit the
990
+ * decision. A node the host has no entry for would otherwise render its
991
+ * definition card — identity and nothing else — inside a box sized for a run,
992
+ * leaving over a hundred pixels of void under it. A run's graph is in run mode
993
+ * for all of its nodes, or none.
994
+ */
995
+ const RunModeContext = createContext(false);
996
+ /** The selected node's ring. An `outline` rather than a border or a box-shadow
997
+ * because both of those are already spoken for — the border carries tone and
998
+ * run status, the shadow carries the running/waiting glow — and an outline
999
+ * composes with both without disturbing the laid-out box. */
1000
+ const SELECTION_OUTLINE = {
1001
+ outline: "2px solid hsl(var(--primary))",
1002
+ outlineOffset: 2
1003
+ };
1004
+ /** Whether the canvas is currently an EDITOR — read by the node so its
1005
+ * connection handles become visible targets rather than the invisible anchors
1006
+ * a read-only diagram wants. A handle you cannot see is a handle you cannot
1007
+ * find, and dragging one is the whole gesture. */
1008
+ const ConnectableContext = createContext(false);
1009
+ /**
1010
+ * The AUTHORING problems anchored to each node, keyed by node id — read by the
1011
+ * node so its card can say it is the broken one.
1012
+ *
1013
+ * Delivered as CONTEXT rather than merged onto node data, unlike run state,
1014
+ * because the two arrive on different clocks. Run state ticks against a fixed
1015
+ * definition, so merging it into `data` costs nothing; a problem list changes
1016
+ * with the DRAFT, and merging it would put the problems into the memo the
1017
+ * layout is keyed on — relaying out the whole canvas on every keystroke of an
1018
+ * invalid draft.
1019
+ */
1020
+ const NodeProblemsContext = createContext(/* @__PURE__ */ new Map());
1021
+ /** Removing the trigger this node stands for, when the host offers it. Null ⇒
1022
+ * no control is drawn. A trigger is the one node with a delete gesture on the
1023
+ * canvas: every other node is a `do` entry, whose removal is a list edit (see
1024
+ * `deletable: false` in flow-graph.ts), while an `on:` entry has no list of its
1025
+ * own to be edited in. */
1026
+ const TriggerDeleteContext = createContext(null);
1027
+ /** Inserting a step ON an edge, from the control the edge draws at its midpoint.
1028
+ * Reaches the edge renderer as context because React Flow edge `data` is
1029
+ * serialisable payload, not a place to hang a host callback. */
1030
+ const EdgeInsertContext = createContext(null);
1031
+ /**
1032
+ * Whether an edge is the HOST's to change. Only a declared edge is: a fork edge
1033
+ * is fan-out structure derived from a structural action's own config, and an
1034
+ * edge out of a trigger is what "nothing points at this node" renders as.
1035
+ * Neither appears in anyone's declared topology, so neither can be connected,
1036
+ * deleted, or guarded — offering the gesture would invite an edit with nowhere
1037
+ * to land.
1038
+ */
1039
+ function isEditableEdge(edge) {
1040
+ return edge.data?.kind !== "fork" && triggerNodeIndex(edge.source) === null;
1041
+ }
1042
+ const HANDLE_CLASS = "!h-2 !w-2 !min-w-0 !border-0 !bg-transparent opacity-0";
1043
+ /** On an EDITABLE canvas the handle stops being a hidden anchor and becomes the
1044
+ * thing you drag. Drawn as a small ring in the muted token so it reads as an
1045
+ * affordance without competing with the node's own mark or status border.
1046
+ *
1047
+ * `!z-10` is load-bearing, not styling: the handle straddles the node card's
1048
+ * edge, and without it the card paints OVER the handle — every pointer event
1049
+ * lands on the card, so a connection drag can never start. Verified with a
1050
+ * live-browser hit-test (`elementFromPoint` returned the card at every pixel
1051
+ * of the handle; the identical drag landed once the handle was raised).
1052
+ * jsdom cannot catch a stacking regression, which is why the class is pinned
1053
+ * by name in WorkflowNode.test.tsx. */
1054
+ const HANDLE_CONNECTABLE_CLASS = "!z-10 !h-2.5 !w-2.5 !min-w-0 !rounded-full !border !border-border !bg-muted-foreground/70 opacity-100 transition-opacity hover:!bg-primary";
1055
+ /**
1056
+ * Where a compact node's edges attach. The compact BOX spans the icon tile AND
1057
+ * its name (so a name can never collide with a neighbour), but the edges belong
1058
+ * to the TILE — at the box's default anchors they would leave from the whitespace
1059
+ * beside the name. These pin each handle to the tile's own edge midpoint.
1060
+ *
1061
+ * Every anchor is given as an explicit `left`/`top` with a centering transform,
1062
+ * overriding React Flow's per-side defaults (which offset by half the handle and
1063
+ * anchor Bottom/Right from the far edge). Restating all four in one coordinate
1064
+ * system is what makes the anchor exactly the tile's edge — and not, say, 4px
1065
+ * past it, which is what the library's own bottom transform would have done.
1066
+ */
1067
+ function compactHandleStyle(position, isLR) {
1068
+ const tileLeft = isLR ? (COMPACT_NODE_SIZE.width - 76) / 2 : 0;
1069
+ const center = 38;
1070
+ const anchor = (left, top) => ({
1071
+ left,
1072
+ top,
1073
+ right: "auto",
1074
+ bottom: "auto",
1075
+ transform: "translate(-50%, -50%)"
1076
+ });
1077
+ switch (position) {
1078
+ case Position.Left: return anchor(tileLeft, center);
1079
+ case Position.Right: return anchor(tileLeft + 76, center);
1080
+ case Position.Top: return anchor(tileLeft + center, 0);
1081
+ default: return anchor(tileLeft + center, 76);
1082
+ }
1083
+ }
1084
+ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
1085
+ const d = data;
1086
+ const state = d.state;
1087
+ const direction = useContext(DirectionContext);
1088
+ const compact = useContext(DensityContext);
1089
+ const runMode = useContext(RunModeContext);
1090
+ const connectable = useContext(ConnectableContext);
1091
+ const hostSelection = useContext(SelectedNodeContext);
1092
+ const problems = useContext(NodeProblemsContext).get(id);
1093
+ const problemSeverity = worstSeverity(problems);
1094
+ const removeTrigger = useContext(TriggerDeleteContext);
1095
+ const isTrigger = triggerNodeIndex(id) !== null;
1096
+ const selected = hostSelection !== void 0 && hostSelection === id;
1097
+ const isLR = direction === "LR";
1098
+ const targetPos = targetPosition ?? (isLR ? Position.Left : Position.Top);
1099
+ const sourcePos = sourcePosition ?? (isLR ? Position.Right : Position.Bottom);
1100
+ const accent = TONE_ACCENT[d.tone];
1101
+ const isAgent = d.kind === "agent.run";
1102
+ const subtitle = isAgent && state?.model ? shortModel(state.model) : d.subtitle;
1103
+ const markModel = isAgent && state?.model ? state.model : d.model;
1104
+ const runBands = runMode && d.tone !== "trigger";
1105
+ const runStatus = runBands ? state?.status ?? "queued" : state?.status;
1106
+ const duration = fmtDuration(state?.durationMs);
1107
+ const cost = fmtCost(state?.costUsd);
1108
+ const tokens = isAgent ? fmtTokens(state?.inputTokens, state?.outputTokens) : void 0;
1109
+ const runOutput = (() => {
1110
+ const source = state?.status === "failed" && state.error ? {
1111
+ text: state.error,
1112
+ tone: "error",
1113
+ label: "Error"
1114
+ } : state && state.status !== "failed" && state.outputPreview ? {
1115
+ text: state.outputPreview,
1116
+ tone: "default",
1117
+ label: "Output"
1118
+ } : null;
1119
+ if (!source) return null;
1120
+ const trimmed = source.text.trim();
1121
+ if (!trimmed) return null;
1122
+ const shape = classifyOutput(clampPreview(trimmed, 240), isAgent && source.tone !== "error");
1123
+ return shape.kind === "empty" ? null : {
1124
+ shape,
1125
+ tone: source.tone,
1126
+ label: source.label
1127
+ };
1128
+ })();
1129
+ /**
1130
+ * Remove this trigger. Two escapes, because the control sits INSIDE a node and
1131
+ * both of the node's own gestures would otherwise fire with it: `nodrag nopan`
1132
+ * is React Flow's own opt-out, so pressing the button never starts a node drag
1133
+ * or a canvas pan, and the click is stopped from bubbling to the wrapper that
1134
+ * carries `onNodeClick` — removing a trigger must not also open it.
1135
+ */
1136
+ const triggerDeleteButton = isTrigger && removeTrigger ? /* @__PURE__ */ jsx("button", {
1137
+ type: "button",
1138
+ "data-testid": "wf-trigger-delete",
1139
+ title: "Remove this trigger",
1140
+ "aria-label": "Remove this trigger",
1141
+ className: "nodrag nopan -m-1 flex h-6 w-6 shrink-0 items-center justify-center p-1 text-muted-foreground transition hover:text-[var(--surface-danger-text)]",
1142
+ onClick: (event) => {
1143
+ event.stopPropagation();
1144
+ removeTrigger(id);
1145
+ },
1146
+ children: /* @__PURE__ */ jsx("span", {
1147
+ className: "flex h-4 w-4 items-center justify-center rounded-full border border-border bg-card shadow-sm",
1148
+ children: /* @__PURE__ */ jsx(X, {
1149
+ size: 10,
1150
+ "aria-hidden": true
1151
+ })
1152
+ })
1153
+ }) : null;
1154
+ const handleClass = connectable ? HANDLE_CONNECTABLE_CLASS : HANDLE_CLASS;
1155
+ const handles = /* @__PURE__ */ jsxs(Fragment, { children: [!d.isRoot && /* @__PURE__ */ jsx(Handle, {
1156
+ type: "target",
1157
+ position: targetPos,
1158
+ className: handleClass,
1159
+ style: compact ? compactHandleStyle(targetPos, isLR) : void 0
1160
+ }), /* @__PURE__ */ jsx(Handle, {
1161
+ type: "source",
1162
+ position: sourcePos,
1163
+ className: handleClass,
1164
+ style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
1165
+ })] });
1166
+ if (compact) return /* @__PURE__ */ jsxs("div", {
1167
+ className: `relative flex h-full w-full ${isLR ? "flex-col items-center" : "flex-row items-center"}`,
1168
+ children: [
1169
+ handles,
1170
+ /* @__PURE__ */ jsxs("span", {
1171
+ "data-testid": "wf-node-card",
1172
+ className: "relative flex items-center justify-center rounded-xl border bg-card shadow-sm transition-colors",
1173
+ style: {
1174
+ width: 76,
1175
+ height: 76,
1176
+ ...state ? statusBorder(state.status) : problemSeverity ? problemBorder(problemSeverity) : { borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))` },
1177
+ ...selected ? SELECTION_OUTLINE : {}
1178
+ },
1179
+ children: [
1180
+ /* @__PURE__ */ jsx("span", {
1181
+ className: "wf-node-body-in flex items-center justify-center",
1182
+ children: /* @__PURE__ */ jsx(NodeMark, {
1183
+ kind: d.kind,
1184
+ provider: d.provider,
1185
+ model: markModel,
1186
+ accent,
1187
+ tile: Math.round(47.12)
1188
+ })
1189
+ }),
1190
+ problems && problemSeverity && /* @__PURE__ */ jsx(ProblemMarker, {
1191
+ problems,
1192
+ className: "-left-1.5 -top-1.5 absolute"
1193
+ }),
1194
+ triggerDeleteButton && /* @__PURE__ */ jsx("span", {
1195
+ className: "-right-1.5 -top-1.5 absolute",
1196
+ children: triggerDeleteButton
1197
+ }),
1198
+ d.badge && !state && /* @__PURE__ */ jsx("span", {
1199
+ className: "-right-1.5 -top-1.5 absolute rounded-full border border-border bg-card px-1.5 py-[1px] font-medium text-[10px] text-muted-foreground",
1200
+ children: d.badge
1201
+ }),
1202
+ state && /* @__PURE__ */ jsx("span", {
1203
+ className: `-right-1 -top-1 absolute h-2.5 w-2.5 rounded-full border-2 ${state.status === "running" ? "animate-pulse" : ""}`,
1204
+ ...state.status === "running" ? { "data-motion": "essential" } : {},
1205
+ style: {
1206
+ background: STATUS_COLOR[state.status],
1207
+ borderColor: "hsl(var(--card))"
1208
+ },
1209
+ "aria-label": STATUS_LABEL[state.status]
1210
+ })
1211
+ ]
1212
+ }),
1213
+ /* @__PURE__ */ jsxs("div", {
1214
+ className: `wf-node-body-in min-w-0 ${isLR ? "mt-2 w-full px-1 text-center" : "ml-2 flex-1 text-left"}`,
1215
+ children: [
1216
+ /* @__PURE__ */ jsx("div", {
1217
+ className: "truncate font-semibold text-[12px] text-foreground leading-tight",
1218
+ title: d.title,
1219
+ children: d.title
1220
+ }),
1221
+ subtitle && /* @__PURE__ */ jsx("div", {
1222
+ className: "truncate text-[11px] text-muted-foreground leading-tight",
1223
+ title: subtitle,
1224
+ children: subtitle
1225
+ }),
1226
+ (duration || cost) && /* @__PURE__ */ jsx("div", {
1227
+ className: "truncate text-[10px] text-muted-foreground/90 leading-tight tabular-nums",
1228
+ children: [duration, cost].filter(Boolean).join(" · ")
1229
+ })
1230
+ ]
1231
+ })
1232
+ ]
1233
+ });
1234
+ const cardShell = (children) => /* @__PURE__ */ jsxs("div", {
1235
+ "data-testid": "wf-node-card",
1236
+ className: "relative flex h-full w-full flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-colors",
1237
+ style: {
1238
+ ...runStatus ? statusBorder(runStatus) : problemSeverity ? problemBorder(problemSeverity) : { borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))` },
1239
+ ...selected ? SELECTION_OUTLINE : {}
1240
+ },
1241
+ children: [handles, children]
1242
+ });
1243
+ const identityRow = /* @__PURE__ */ jsxs("div", {
1244
+ className: "flex shrink-0 items-center gap-2.5",
1245
+ children: [
1246
+ /* @__PURE__ */ jsx(NodeMark, {
1247
+ kind: d.kind,
1248
+ provider: d.provider,
1249
+ model: markModel,
1250
+ accent,
1251
+ tile: 34
1252
+ }),
1253
+ /* @__PURE__ */ jsxs("div", {
1254
+ className: "min-w-0 flex-1",
1255
+ children: [/* @__PURE__ */ jsx("div", {
1256
+ className: "truncate font-semibold text-[13px] text-foreground leading-tight",
1257
+ title: d.title,
1258
+ children: d.title
1259
+ }), subtitle && /* @__PURE__ */ jsx("div", {
1260
+ className: "truncate text-[11px] text-muted-foreground leading-tight",
1261
+ title: subtitle,
1262
+ children: subtitle
1263
+ })]
1264
+ }),
1265
+ problems && problemSeverity && /* @__PURE__ */ jsx(ProblemMarker, { problems }),
1266
+ runStatus ? /* @__PURE__ */ jsx(StatusPill, { status: runStatus }) : d.badge && /* @__PURE__ */ jsx("span", {
1267
+ className: "shrink-0 rounded-full border border-border bg-surface-container-high px-2 py-[1px] font-medium text-[10px] text-muted-foreground",
1268
+ children: d.badge
1269
+ }),
1270
+ triggerDeleteButton
1271
+ ]
1272
+ });
1273
+ const footer = runStatus ? /* @__PURE__ */ jsx(StatusFooter, {
1274
+ status: runStatus,
1275
+ rounds: isAgent ? state?.rounds : void 0,
1276
+ cost,
1277
+ tokens,
1278
+ elapsed: duration
1279
+ }) : null;
1280
+ const descriptionBand = (emphasized) => d.description ? /* @__PURE__ */ jsx("p", {
1281
+ className: `mt-2 line-clamp-2 shrink-0 text-[11px] leading-snug ${emphasized ? "text-foreground" : "text-muted-foreground"}`,
1282
+ title: d.description,
1283
+ children: d.description
1284
+ }) : null;
1285
+ const emptySlot = runStatus && !runOutput && emptySlotLabel(runStatus) ? /* @__PURE__ */ jsx("div", {
1286
+ className: "mt-2 flex flex-1 items-center text-[11px] text-muted-foreground italic",
1287
+ children: emptySlotLabel(runStatus)
1288
+ }) : null;
1289
+ const outputWell = (rows) => runOutput ? /* @__PURE__ */ jsxs("div", {
1290
+ className: "mt-2 min-h-0 grow shrink-0 rounded-lg border border-border bg-surface-container-high/60 px-2 py-1.5",
1291
+ children: [/* @__PURE__ */ jsx("div", {
1292
+ className: "mb-1 font-semibold text-[9px] text-muted-foreground uppercase tracking-[0.09em]",
1293
+ children: runOutput.label
1294
+ }), /* @__PURE__ */ jsx(NodeOutputBody, {
1295
+ shape: runOutput.shape,
1296
+ tone: runOutput.tone,
1297
+ rows
1298
+ })]
1299
+ }) : emptySlot;
1300
+ const body = (children, justify = "") => /* @__PURE__ */ jsx("div", {
1301
+ className: `wf-node-body-in flex min-h-0 flex-1 flex-col overflow-hidden px-3.5 pt-2.5 pb-2 ${justify}`,
1302
+ children
1303
+ });
1304
+ if (!runBands) return cardShell(body(/* @__PURE__ */ jsxs(Fragment, { children: [identityRow, descriptionBand(false)] })));
1305
+ if (d.kind === "decision") return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
1306
+ className: "flex shrink-0 items-start gap-2.5",
1307
+ children: [/* @__PURE__ */ jsx(NodeMark, {
1308
+ kind: d.kind,
1309
+ provider: d.provider,
1310
+ model: markModel,
1311
+ accent,
1312
+ tile: 34
1313
+ }), /* @__PURE__ */ jsxs("div", {
1314
+ className: "min-w-0 flex-1",
1315
+ children: [/* @__PURE__ */ jsx("div", {
1316
+ className: "truncate font-semibold text-[13px] text-foreground leading-tight",
1317
+ title: d.title,
1318
+ children: d.title
1319
+ }), /* @__PURE__ */ jsxs("div", {
1320
+ className: "mt-0.5 flex items-center gap-2",
1321
+ children: [subtitle && /* @__PURE__ */ jsx("span", {
1322
+ className: "truncate text-[11px] text-muted-foreground leading-tight",
1323
+ title: subtitle,
1324
+ children: subtitle
1325
+ }), runStatus && /* @__PURE__ */ jsx("span", {
1326
+ className: "ml-auto shrink-0",
1327
+ children: /* @__PURE__ */ jsx(StatusPill, { status: runStatus })
1328
+ })]
1329
+ })]
1330
+ })]
1331
+ }), runOutput && runStatus === "failed" ? /* @__PURE__ */ jsx("div", {
1332
+ className: "mt-2 min-h-0 shrink-0",
1333
+ children: /* @__PURE__ */ jsx(NodeOutputBody, {
1334
+ shape: runOutput.shape,
1335
+ tone: runOutput.tone,
1336
+ rows: 2
1337
+ })
1338
+ }) : descriptionBand(true)] })), footer] }));
1339
+ if (isStructuralKind(d.kind)) return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [identityRow, /* @__PURE__ */ jsx("div", {
1340
+ className: "mt-1.5 min-h-0 shrink-0",
1341
+ children: runOutput && /* @__PURE__ */ jsx(NodeOutputBody, {
1342
+ shape: runOutput.shape,
1343
+ tone: runOutput.tone,
1344
+ rows: 1
1345
+ })
1346
+ })] }), "justify-center"), footer] }));
1347
+ if (isAgent) {
1348
+ const strip = [d.title, subtitle].filter(Boolean).join(" · ");
1349
+ return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
1350
+ className: "flex shrink-0 items-center gap-1.5",
1351
+ children: [
1352
+ /* @__PURE__ */ jsx(NodeMark, {
1353
+ kind: d.kind,
1354
+ provider: d.provider,
1355
+ model: markModel,
1356
+ accent,
1357
+ tile: 18
1358
+ }),
1359
+ /* @__PURE__ */ jsx("span", {
1360
+ className: "truncate font-medium text-[11px] text-muted-foreground",
1361
+ title: strip,
1362
+ children: strip
1363
+ }),
1364
+ /* @__PURE__ */ jsx("span", { className: "flex-1" }),
1365
+ runStatus && /* @__PURE__ */ jsx(StatusPill, { status: runStatus })
1366
+ ]
1367
+ }), /* @__PURE__ */ jsx("div", {
1368
+ className: "mt-2 min-h-0 flex-1 text-foreground leading-snug",
1369
+ children: runOutput ? /* @__PURE__ */ jsx(NodeOutputBody, {
1370
+ shape: runOutput.shape,
1371
+ tone: runOutput.tone,
1372
+ rows: 5
1373
+ }) : runStatus && runStatus !== "succeeded" && runStatus !== "failed" && d.description ? /* @__PURE__ */ jsx("p", {
1374
+ className: "line-clamp-5 text-[11px] text-muted-foreground leading-snug",
1375
+ title: d.description,
1376
+ children: d.description
1377
+ }) : runStatus && /* @__PURE__ */ jsx("span", {
1378
+ className: "text-muted-foreground italic",
1379
+ children: emptySlotLabel(runStatus)
1380
+ })
1381
+ })] })), footer] }));
1382
+ }
1383
+ return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [identityRow, outputWell(3)] })), footer] }));
1384
+ }
1385
+ /** Where a connection drag was let go, in viewport coordinates. A touch reports
1386
+ * its position on `changedTouches` — `touches` is empty by the time the last
1387
+ * finger lifts. Null when the event carries no position at all. */
1388
+ function releasePoint(event) {
1389
+ if (!event) return null;
1390
+ const source = ("changedTouches" in event ? event.changedTouches[0] : null) ?? ("clientX" in event ? event : null);
1391
+ return source ? {
1392
+ x: source.clientX,
1393
+ y: source.clientY
1394
+ } : null;
1395
+ }
1396
+ /** The empty canvas itself: React Flow's pane, and the dot grid drawn on it.
1397
+ * Everything else in the graph — a node, an edge, a label, the zoom controls,
1398
+ * the panel — sits ABOVE the pane and is therefore not it. */
1399
+ const CANVAS_SURFACE_SELECTOR = ".react-flow__pane, .react-flow__background";
1400
+ /**
1401
+ * Whether a release at this point landed on THIS graph's empty canvas.
1402
+ *
1403
+ * Identified POSITIVELY — the element under the pointer must BE the pane — and
1404
+ * not by listing what to exclude. A blacklist has to enumerate every layer that
1405
+ * can sit over the canvas (nodes, edges, edge labels, the controls, the panel,
1406
+ * whatever is added next), and each one it misses turns a release meant for that
1407
+ * element into a step nobody asked for. Asking "is this the pane" cannot miss
1408
+ * one, and it fails CLOSED: an environment with no hit-testing adds nothing
1409
+ * rather than adding something wrong.
1410
+ *
1411
+ * The pane also has to be OURS. React Flow completes a connection from a
1412
+ * document-level pointer-up, so a drag begun in one graph is still live over
1413
+ * every other graph on the page — a proposal preview beside an editor, a gallery
1414
+ * of them — and a bare "is this a pane" would let a release over a NEIGHBOUR add
1415
+ * a step to the draft the drag started in. Containment settles it, and carries
1416
+ * the frame check with it: a release off the graph lands on the page rather than
1417
+ * on anything this frame holds.
1418
+ */
1419
+ function releasedOnCanvas(at, frame) {
1420
+ if (!frame) return false;
1421
+ const under = typeof document === "undefined" ? null : document.elementFromPoint(at.x, at.y);
1422
+ if (!under?.matches(CANVAS_SURFACE_SELECTOR)) return false;
1423
+ return frame.contains(under);
1424
+ }
1425
+ /**
1426
+ * The laid-out node boxes an edge's cluster has to keep clear of, in the same
1427
+ * flow coordinates the edge's own label point is expressed in.
1428
+ *
1429
+ * Taken from the LAYOUT rather than from React Flow's node store. The layout's
1430
+ * dimensions are authoritative — the card renders at exactly that box (see
1431
+ * `WfNode.height`) — so they need no measurement pass to become true, and they
1432
+ * change only when the layout does, which is precisely when a cluster needs to
1433
+ * be placed again. Reading the store instead would mean subscribing to a map
1434
+ * React Flow mutates in place, whose identity never changes and so never
1435
+ * notifies: the clearance would be computed once against unmeasured nodes and
1436
+ * then never revisited.
1437
+ */
1438
+ const NodeBoxesContext = createContext([]);
1439
+ /** Chip styling shared by the guard summary and the cycle badge, so an edge's
1440
+ * two possible annotations read as one pair rather than two designs. */
1441
+ const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-[1px] text-[10px] leading-tight backdrop-blur";
1442
+ /**
1443
+ * An edge with something to say: a guard summary, a cycle marker, an authoring
1444
+ * problem, or the insert control an EDITING canvas draws on it. An edge with
1445
+ * none of those stays on React Flow's built-in `smoothstep` renderer and is
1446
+ * unaffected by this component's existence.
1447
+ *
1448
+ * The guard chip is truncated with its full text on `title`: a summary is
1449
+ * usually a few words, but it is host-supplied and nothing bounds it, and an
1450
+ * unbounded label on an edge overlaps the nodes either side of it.
1451
+ */
1452
+ function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
1453
+ const insertStep = useContext(EdgeInsertContext);
1454
+ const [path, labelX, labelY] = getSmoothStepPath({
1455
+ sourceX,
1456
+ sourceY,
1457
+ sourcePosition,
1458
+ targetX,
1459
+ targetY,
1460
+ targetPosition
1461
+ });
1462
+ const backEdge = data?.backEdge === true;
1463
+ const whenLabel = data?.whenLabel;
1464
+ const visits = data?.maxNodeVisits;
1465
+ const problems = data?.problems;
1466
+ const problemSeverity = worstSeverity(problems);
1467
+ const offersInsert = data?.insertable === true && insertStep !== null;
1468
+ const nodeBoxes = useContext(NodeBoxesContext);
1469
+ const direction = useContext(DirectionContext);
1470
+ const cleared = offersInsert || problemSeverity ? clearOfNodeBoxes({
1471
+ x: labelX,
1472
+ y: labelY
1473
+ }, nodeBoxes, direction, problemSeverity ? CLUSTER_HALF_SIZE.chip : CLUSTER_HALF_SIZE.control) : 0;
1474
+ const clearance = cleared ?? 0;
1475
+ const stealsPointer = cleared === null;
1476
+ const isLR = direction === "LR";
1477
+ const clusterX = labelX + (isLR ? 0 : clearance);
1478
+ const clusterY = labelY + (isLR ? clearance : 0);
1479
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(BaseEdge, {
1480
+ id,
1481
+ path,
1482
+ markerEnd,
1483
+ style
1484
+ }), /* @__PURE__ */ jsx(EdgeLabelRenderer, { children: /* @__PURE__ */ jsxs("div", {
1485
+ className: "nodrag nopan absolute flex flex-col items-center gap-0.5",
1486
+ style: {
1487
+ zIndex: 1,
1488
+ transform: `translate(-50%, -50%) translate(${clusterX}px, ${clusterY}px)`
1489
+ },
1490
+ children: [
1491
+ problems && problemSeverity && /* @__PURE__ */ jsxs("span", {
1492
+ "data-testid": "wf-edge-problem",
1493
+ "data-severity": problemSeverity,
1494
+ className: `${EDGE_CHIP_CLASS} max-w-40 truncate ${stealsPointer ? "" : "pointer-events-auto"}`,
1495
+ style: problemSurface(problemSeverity),
1496
+ children: [/* @__PURE__ */ jsx("span", {
1497
+ "aria-hidden": true,
1498
+ title: problemTitle(problems),
1499
+ children: problems.length === 1 ? problems[0].message : `${problems.length} problems`
1500
+ }), /* @__PURE__ */ jsx(ProblemMessages, { problems })]
1501
+ }),
1502
+ whenLabel && /* @__PURE__ */ jsx("span", {
1503
+ title: whenLabel,
1504
+ className: `${EDGE_CHIP_CLASS} max-w-40 truncate text-muted-foreground`,
1505
+ children: whenLabel
1506
+ }),
1507
+ backEdge && /* @__PURE__ */ jsx("span", {
1508
+ title: visits !== void 0 ? `Loops back — each node runs at most ${visits} time${visits === 1 ? "" : "s"} per run` : "Loops back to a step that has already run",
1509
+ className: `${EDGE_CHIP_CLASS} shrink-0 text-foreground`,
1510
+ children: visits !== void 0 ? `↺ ≤${visits}` : "↺"
1511
+ }),
1512
+ offersInsert && /* @__PURE__ */ jsx("button", {
1513
+ type: "button",
1514
+ "data-testid": "wf-edge-insert",
1515
+ title: "Insert a step here",
1516
+ "aria-label": "Insert a step here",
1517
+ className: `flex h-5 w-5 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm backdrop-blur transition hover:border-primary hover:text-primary ${stealsPointer ? "" : "pointer-events-auto"}`,
1518
+ onClick: (event) => {
1519
+ event.stopPropagation();
1520
+ insertStep(source, target);
1521
+ },
1522
+ children: /* @__PURE__ */ jsx(Plus, {
1523
+ size: 12,
1524
+ "aria-hidden": true
1525
+ })
1526
+ })
1527
+ ]
1528
+ }) })] });
1529
+ }
1530
+ const NODE_TYPES = { wfNode: WorkflowNode };
1531
+ const EDGE_TYPES = { [WF_EDGE_TYPE]: WfEdgeRenderer };
1532
+ function buildStyledEdges(base, nodeState, maxNodeVisits) {
1533
+ return base.map((e) => {
1534
+ const status = nodeState?.[e.target]?.status;
1535
+ const color = edgeColor(nodeState ? status : void 0);
1536
+ const backEdge = e.data?.backEdge === true;
1537
+ return {
1538
+ ...e,
1539
+ animated: !backEdge && status === "running",
1540
+ style: {
1541
+ strokeWidth: 1.75,
1542
+ stroke: color,
1543
+ ...backEdge ? { strokeDasharray: "6 3" } : {}
1544
+ },
1545
+ markerEnd: {
1546
+ type: MarkerType.ArrowClosed,
1547
+ width: 15,
1548
+ height: 15,
1549
+ color
1550
+ },
1551
+ ...backEdge && maxNodeVisits !== void 0 && e.data ? { data: {
1552
+ ...e.data,
1553
+ maxNodeVisits
1554
+ } } : {}
1555
+ };
1556
+ });
1557
+ }
1558
+ /**
1559
+ * Fold the AUTHORING decorations onto already-styled edges: the problems
1560
+ * anchored to each one, and whether it offers the insert control. Both are drawn
1561
+ * by the custom renderer, so an edge that gains either is switched onto it —
1562
+ * everything else keeps the built-in `smoothstep` it has always had.
1563
+ *
1564
+ * A problem also RECOLOURS the edge, from the same table the node it points at
1565
+ * is tinted from, because a chip alone is unreadable at the zoom a whole
1566
+ * pipeline is viewed at — but only while the graph is NOT showing a run.
1567
+ * A run's colour is the more urgent reading of the same line and wins, exactly
1568
+ * as a node's run border wins over its problem border; the chip still renders,
1569
+ * so nothing is lost on the one edge that could carry both.
1570
+ *
1571
+ * `insertable` is a predicate rather than a flag so this pass never has to
1572
+ * re-derive which edges are the host's to change — {@link isEditableEdge}
1573
+ * already answers that for delete and guard.
1574
+ */
1575
+ function decorateAuthoringEdges(edges, byEdge, insertable, hasRunOverlay) {
1576
+ if (byEdge.size === 0 && !edges.some(insertable)) return edges;
1577
+ return edges.map((e) => {
1578
+ const problems = byEdge.get(e.id);
1579
+ const severity = worstSeverity(problems);
1580
+ const offersInsert = insertable(e);
1581
+ if (!severity && !offersInsert) return e;
1582
+ const stroke = severity && !hasRunOverlay ? problemSurface(severity).color : void 0;
1583
+ return {
1584
+ ...e,
1585
+ type: WF_EDGE_TYPE,
1586
+ data: {
1587
+ ...e.data ?? { kind: "spine" },
1588
+ ...problems ? { problems } : {},
1589
+ ...offersInsert ? { insertable: true } : {}
1590
+ },
1591
+ ...stroke ? {
1592
+ style: {
1593
+ ...e.style,
1594
+ stroke
1595
+ },
1596
+ markerEnd: e.markerEnd && typeof e.markerEnd === "object" ? {
1597
+ ...e.markerEnd,
1598
+ color: stroke
1599
+ } : e.markerEnd
1600
+ } : {}
1601
+ };
1602
+ });
1603
+ }
1604
+ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompact, onCompactChange, className, wrap = false, nodeState, edges: declaredEdges, maxNodeVisits, selectedNodeId, onNodeClick, onEdgeConnect, onEdgeDelete, onEdgeClick, onEdgeInsert, onNodeInsert, onTriggerAdd, onTriggerDelete, problems }) {
1605
+ const editable = onEdgeConnect !== void 0;
1606
+ const insertOnEdge = editable ? onEdgeInsert : void 0;
1607
+ const insertArmed = insertOnEdge !== void 0;
1608
+ const insertAtNode = editable ? onNodeInsert : void 0;
1609
+ const addTrigger = editable ? onTriggerAdd : void 0;
1610
+ const deleteTrigger = editable ? onTriggerDelete : void 0;
1611
+ const colorMode = useColorMode();
1612
+ const isPreview = variant === "preview";
1613
+ const [userCompact, setUserCompact] = useState(defaultCompact ?? nodeState === void 0);
1614
+ /**
1615
+ * What the NEXT press toggles away from.
1616
+ *
1617
+ * The press cannot read `userCompact` for this: two presses that land in one
1618
+ * batch share a render, so both would see the same density, both would
1619
+ * compute the same flip, and two presses would move the graph once — while
1620
+ * reporting that one density twice to a host that persists it. The ref
1621
+ * advances on the press itself, so the second press sees what the first
1622
+ * chose. It is the toggle's own bookkeeping; the RENDER still follows
1623
+ * `userCompact`, which is the only thing React may read for that.
1624
+ */
1625
+ const nextDensityFrom = useRef(userCompact);
1626
+ const compact = isPreview || userCompact;
1627
+ const [displayCompact, setDisplayCompact] = useState(compact);
1628
+ const rfRef = useRef(null);
1629
+ const wrapperRef = useRef(null);
1630
+ const framedRef = useRef(false);
1631
+ const [framingCue, setFramingCue] = useState(0);
1632
+ const hasRunOverlay = nodeState !== void 0;
1633
+ const structural = useMemo(() => buildFlowGraph(yaml, {
1634
+ nodeState: hasRunOverlay ? {} : void 0,
1635
+ direction,
1636
+ compact,
1637
+ wrap,
1638
+ reserveEdgeInsert: insertArmed,
1639
+ ...declaredEdges ? { edges: declaredEdges } : {}
1640
+ }), [
1641
+ yaml,
1642
+ hasRunOverlay,
1643
+ direction,
1644
+ compact,
1645
+ wrap,
1646
+ insertArmed,
1647
+ declaredEdges
1648
+ ]);
1649
+ const nodeBoxes = useMemo(() => structural.nodes.map((node) => ({
1650
+ x: node.position.x,
1651
+ y: node.position.y,
1652
+ width: node.width ?? 0,
1653
+ height: node.height ?? 0
1654
+ })), [structural]);
1655
+ const problemIndex = useMemo(() => indexProblems(problems), [problems]);
1656
+ const styledEdges = useMemo(() => {
1657
+ return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
1658
+ ...e,
1659
+ deletable: editable ? isEditableEdge(e) : false
1660
+ }));
1661
+ }, [
1662
+ structural.edges,
1663
+ nodeState,
1664
+ maxNodeVisits,
1665
+ editable,
1666
+ problemIndex,
1667
+ insertArmed,
1668
+ hasRunOverlay
1669
+ ]);
1670
+ const [nodes, setNodes, onNodesChange] = useNodesState(structural.nodes);
1671
+ const [edges, setEdges, onEdgesChange] = useEdgesState(styledEdges);
1672
+ const nodesRef = useRef(nodes);
1673
+ nodesRef.current = nodes;
1674
+ const nodeStateRef = useRef(nodeState);
1675
+ nodeStateRef.current = nodeState;
1676
+ useEffect(() => {
1677
+ setEdges(styledEdges);
1678
+ }, [styledEdges, setEdges]);
1679
+ useEffect(() => {
1680
+ const baseById = new Map(structural.nodes.map((n) => [n.id, n.data]));
1681
+ setNodes((prev) => mergeRunState(prev, baseById, nodeState));
1682
+ }, [
1683
+ nodeState,
1684
+ structural,
1685
+ setNodes
1686
+ ]);
1687
+ const didInitialSeedRef = useRef(false);
1688
+ useEffect(() => {
1689
+ const withRunState = (nodes) => {
1690
+ const state = nodeStateRef.current;
1691
+ if (!state) return nodes;
1692
+ return nodes.map((n) => state[n.id] ? {
1693
+ ...n,
1694
+ data: {
1695
+ ...n.data,
1696
+ state: state[n.id]
1697
+ }
1698
+ } : n);
1699
+ };
1700
+ if (!didInitialSeedRef.current) {
1701
+ didInitialSeedRef.current = true;
1702
+ setNodes(withRunState(structural.nodes));
1703
+ setDisplayCompact(compact);
1704
+ return;
1705
+ }
1706
+ const inst = rfRef.current;
1707
+ const frame = wrapperRef.current?.getBoundingClientRect();
1708
+ if (!inst || !frame || frame.width === 0 || frame.height === 0) {
1709
+ setNodes(withRunState(structural.nodes));
1710
+ setDisplayCompact(compact);
1711
+ return;
1712
+ }
1713
+ const endViewport = framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact);
1714
+ framedRef.current = true;
1715
+ const prev = nodesRef.current;
1716
+ const prevById = new Map(prev.map((n) => [n.id, n]));
1717
+ if (!(prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id))) || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
1718
+ setNodes(withRunState(structural.nodes));
1719
+ setDisplayCompact(compact);
1720
+ inst.setViewport(endViewport);
1721
+ return;
1722
+ }
1723
+ const flipTimer = setTimeout(() => setDisplayCompact(compact), 220 * (compact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
1724
+ const startViewport = inst.getViewport();
1725
+ const startedAt = performance.now();
1726
+ let raf = requestAnimationFrame(function step(now) {
1727
+ const t = Math.min(1, (now - startedAt) / 220);
1728
+ const e = easeInOutCubic(t);
1729
+ setNodes(withRunState(t >= 1 ? structural.nodes : structural.nodes.map((n) => {
1730
+ const p = prevById.get(n.id);
1731
+ if (!p || p.width === void 0 || p.height === void 0) return n;
1732
+ const width = lerp(p.width, n.width ?? p.width, e);
1733
+ const height = lerp(p.height, n.height ?? p.height, e);
1734
+ return {
1735
+ ...n,
1736
+ position: {
1737
+ x: lerp(p.position.x, n.position.x, e),
1738
+ y: lerp(p.position.y, n.position.y, e)
1739
+ },
1740
+ width,
1741
+ height,
1742
+ style: {
1743
+ ...n.style,
1744
+ width,
1745
+ height
1746
+ }
1747
+ };
1748
+ })));
1749
+ inst.setViewport({
1750
+ x: lerp(startViewport.x, endViewport.x, e),
1751
+ y: lerp(startViewport.y, endViewport.y, e),
1752
+ zoom: lerp(startViewport.zoom, endViewport.zoom, e)
1753
+ });
1754
+ if (t < 1) raf = requestAnimationFrame(step);
1755
+ });
1756
+ return () => {
1757
+ cancelAnimationFrame(raf);
1758
+ clearTimeout(flipTimer);
1759
+ };
1760
+ }, [
1761
+ structural,
1762
+ compact,
1763
+ setNodes
1764
+ ]);
1765
+ /**
1766
+ * Own the INITIAL frame once the instance and canvas have a size. React
1767
+ * Flow's one-time `fitView` can run after this effect and overwrite the
1768
+ * anchored camera, leaving the entry node off-screen. Only this effect may
1769
+ * set the initial camera; later resizes leave the reader's pan alone.
1770
+ */
1771
+ useEffect(() => {
1772
+ if (framedRef.current) return;
1773
+ const inst = rfRef.current;
1774
+ const frame = wrapperRef.current?.getBoundingClientRect();
1775
+ if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
1776
+ inst.setViewport(framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact));
1777
+ framedRef.current = true;
1778
+ }, [
1779
+ framingCue,
1780
+ structural,
1781
+ compact
1782
+ ]);
1783
+ useEffect(() => {
1784
+ const el = wrapperRef.current;
1785
+ if (!el || typeof ResizeObserver === "undefined") return;
1786
+ const observer = new ResizeObserver(() => {
1787
+ if (framedRef.current) {
1788
+ observer.disconnect();
1789
+ return;
1790
+ }
1791
+ setFramingCue((cue) => cue + 1);
1792
+ });
1793
+ observer.observe(el);
1794
+ return () => observer.disconnect();
1795
+ }, []);
1796
+ const handleNodeClick = useCallback((_event, node) => {
1797
+ onNodeClick?.(node.id, node.data);
1798
+ }, [onNodeClick]);
1799
+ /**
1800
+ * A connection dragged from a handle and released over EMPTY CANVAS: the
1801
+ * "and then what?" gesture, reported as the node it left and which end of it.
1802
+ *
1803
+ * Three things have to be true, and each rules out a different non-gesture:
1804
+ *
1805
+ * - Nothing was under the pointer (`toNode`/`toHandle`). React Flow ends
1806
+ * EVERY connection drag through this callback, including one that landed on
1807
+ * a handle and already went out through `onConnect`.
1808
+ * - The release landed on THIS graph's empty canvas — its own pane,
1809
+ * positively identified.
1810
+ * This is what rules out everything the first test does not: React Flow
1811
+ * sets `toNode` only when a handle was found within its connection radius,
1812
+ * so letting go in the middle of a wide card reports no target at all, and
1813
+ * without this a release aimed at a node added a step instead. It rules out
1814
+ * the same way a release on the zoom controls, on an edge, or right off the
1815
+ * graph does — React Flow listens for the pointer-up on the document, so
1816
+ * letting go anywhere on the page ends the drag, and pulling away from the
1817
+ * canvas is exactly how a gesture is abandoned.
1818
+ *
1819
+ * The gesture also needs both of its own ends: a drag that reports no node or
1820
+ * no handle it started from names nothing to insert beside.
1821
+ */
1822
+ const handleConnectEnd = useCallback((event, state) => {
1823
+ if (!insertAtNode) return;
1824
+ if (state.toNode || state.toHandle) return;
1825
+ const from = state.fromNode;
1826
+ const fromHandle = state.fromHandle;
1827
+ if (!from || !fromHandle) return;
1828
+ const at = releasePoint(event);
1829
+ if (!at || !releasedOnCanvas(at, wrapperRef.current)) return;
1830
+ insertAtNode(from.id, fromHandle.type === "target" ? "before" : "after");
1831
+ }, [insertAtNode]);
1832
+ /**
1833
+ * Enter/Space on a focused node opens its detail, exactly as a click does.
1834
+ * React Flow makes a node TABBABLE but gives it no activation key of its own,
1835
+ * so a keyboard user could reach every node and open none of them — the
1836
+ * detail panel was mouse-only.
1837
+ *
1838
+ * The handler sits on the flow wrapper and reads the node id off the focused
1839
+ * element, because React Flow reports keys from the canvas rather than
1840
+ * per-node: `onNodeClick` has no keyboard counterpart to hang this on.
1841
+ */
1842
+ const handleNodeKeyDown = useCallback((event) => {
1843
+ if (event.key !== "Enter" && event.key !== " ") return;
1844
+ if (!(event.target instanceof HTMLElement)) return;
1845
+ const nodeId = event.target.closest(".react-flow__node")?.getAttribute("data-id");
1846
+ if (!nodeId) return;
1847
+ const node = nodesRef.current.find((n) => n.id === nodeId);
1848
+ if (!node) return;
1849
+ event.preventDefault();
1850
+ event.stopPropagation();
1851
+ onNodeClick?.(node.id, node.data);
1852
+ }, [onNodeClick]);
1853
+ if (structural.error || structural.nodes.length === 0) return /* @__PURE__ */ jsx("div", {
1854
+ className: `flex items-center justify-center rounded-lg border border-border border-dashed p-4 text-center text-muted-foreground text-xs ${className ?? ""}`,
1855
+ children: structural.error ?? "Nothing to show"
1856
+ });
1857
+ return /* @__PURE__ */ jsx(DirectionContext.Provider, {
1858
+ value: direction,
1859
+ children: /* @__PURE__ */ jsx(DensityContext.Provider, {
1860
+ value: displayCompact,
1861
+ children: /* @__PURE__ */ jsx(RunModeContext.Provider, {
1862
+ value: hasRunOverlay,
1863
+ children: /* @__PURE__ */ jsx(SelectedNodeContext.Provider, {
1864
+ value: selectedNodeId,
1865
+ children: /* @__PURE__ */ jsx(ConnectableContext.Provider, {
1866
+ value: editable,
1867
+ children: /* @__PURE__ */ jsx(NodeBoxesContext.Provider, {
1868
+ value: nodeBoxes,
1869
+ children: /* @__PURE__ */ jsx(NodeProblemsContext.Provider, {
1870
+ value: problemIndex.byNode,
1871
+ children: /* @__PURE__ */ jsx(TriggerDeleteContext.Provider, {
1872
+ value: deleteTrigger ?? null,
1873
+ children: /* @__PURE__ */ jsx(EdgeInsertContext.Provider, {
1874
+ value: insertOnEdge ?? null,
1875
+ children: /* @__PURE__ */ jsx("div", {
1876
+ ref: wrapperRef,
1877
+ className: `wf-graph ${className ?? ""}`,
1878
+ children: /* @__PURE__ */ jsxs(ReactFlow, {
1879
+ nodes,
1880
+ edges,
1881
+ onNodesChange,
1882
+ onEdgesChange,
1883
+ nodeTypes: NODE_TYPES,
1884
+ edgeTypes: EDGE_TYPES,
1885
+ colorMode,
1886
+ onInit: (inst) => {
1887
+ rfRef.current = inst;
1888
+ if (!framedRef.current) setFramingCue((cue) => cue + 1);
1889
+ },
1890
+ onNodeClick: onNodeClick ? handleNodeClick : void 0,
1891
+ onKeyDown: onNodeClick ? handleNodeKeyDown : void 0,
1892
+ proOptions: { hideAttribution: true },
1893
+ nodesDraggable: !isPreview,
1894
+ nodesConnectable: editable,
1895
+ deleteKeyCode: editable ? ["Backspace", "Delete"] : null,
1896
+ edgesReconnectable: false,
1897
+ elementsSelectable: !isPreview,
1898
+ edgesFocusable: editable,
1899
+ onConnect: editable ? (connection) => {
1900
+ if (connection.source && connection.target) onEdgeConnect(connection.source, connection.target);
1901
+ } : void 0,
1902
+ onEdgesDelete: editable ? (deleted) => {
1903
+ for (const edge of deleted) if (isEditableEdge(edge)) onEdgeDelete?.(edge.source, edge.target);
1904
+ } : void 0,
1905
+ onConnectEnd: insertAtNode ? handleConnectEnd : void 0,
1906
+ onEdgeClick: editable ? (_event, edge) => {
1907
+ if (isEditableEdge(edge)) onEdgeClick?.(edge.source, edge.target);
1908
+ } : void 0,
1909
+ zoomOnScroll: false,
1910
+ zoomOnPinch: true,
1911
+ zoomOnDoubleClick: !isPreview,
1912
+ panOnDrag: true,
1913
+ preventScrolling: false,
1914
+ minZoom: .2,
1915
+ maxZoom: 1.5,
1916
+ children: [/* @__PURE__ */ jsx(Background, { gap: 18 }), !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
1917
+ showInteractive: false,
1918
+ position: "bottom-right",
1919
+ fitViewOptions: { padding: FIT_VIEW.padding }
1920
+ }), /* @__PURE__ */ jsxs(Panel, {
1921
+ position: "top-left",
1922
+ className: "flex items-center gap-1",
1923
+ children: [addTrigger && /* @__PURE__ */ jsxs("button", {
1924
+ type: "button",
1925
+ "data-testid": "wf-trigger-add",
1926
+ onClick: addTrigger,
1927
+ title: "Add a trigger",
1928
+ "aria-label": "Add a trigger",
1929
+ className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
1930
+ children: [/* @__PURE__ */ jsx(Plus, {
1931
+ size: 12,
1932
+ "aria-hidden": true
1933
+ }), "Trigger"]
1934
+ }), /* @__PURE__ */ jsxs("button", {
1935
+ type: "button",
1936
+ onClick: () => {
1937
+ const next = !nextDensityFrom.current;
1938
+ nextDensityFrom.current = next;
1939
+ setUserCompact(next);
1940
+ onCompactChange?.(next);
1941
+ },
1942
+ "aria-pressed": userCompact,
1943
+ title: userCompact ? "Expand nodes" : "Collapse nodes",
1944
+ className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
1945
+ children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
1946
+ })]
1947
+ })] })]
1948
+ })
1949
+ })
1950
+ })
1951
+ })
1952
+ })
1953
+ })
1954
+ })
1955
+ })
1956
+ })
1957
+ })
1958
+ });
1959
+ }
1960
+ //#endregion
1961
+ export { WorkflowGraph };