@tangle-network/sandbox-ui 0.134.12 → 0.136.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.
@@ -1,8 +1,8 @@
1
- import { A as triggerNodeIndex, B as fmtTokens, C as COMPACT_NODE_SIZE, D as buildWorkflowGraph, I as shortModel, L as clampPreview, M as worstSeverity, O as isStructuralKind, R as fmtCost, S as statusBorder, _ as emptySlotLabel, a as NodeMark, b as problemSurface, c as RESTING_BORDER, d as STATUS_LABEL, g as edgeColor, h as StatusPill, j as wfEdgeId, l as RESTING_SHADOW, m as StatusFooter, n as classifyOutput, o as ProblemMarker, s as ProblemMessages, t as NodeOutputBody, u as STATUS_COLOR, v as kindAccent, x as problemTitle, y as problemBorder, z as fmtDuration } from "./node-output-CHlf3tni.js";
1
+ import { A as triggerNodeIndex, B as fmtTokens, C as COMPACT_NODE_SIZE, D as buildWorkflowGraph, I as shortModel, L as clampPreview, M as worstSeverity, O as isStructuralKind, R as fmtCost, S as statusBorder, _ as emptySlotLabel, a as NodeMark, b as problemSurface, c as RESTING_BORDER, d as STATUS_LABEL, g as edgeColor, h as StatusPill, j as wfEdgeId, l as RESTING_SHADOW, m as StatusFooter, n as classifyOutput, o as ProblemMarker, s as ProblemMessages, t as NodeOutputBody, u as STATUS_COLOR, v as kindAccent, x as problemTitle, y as problemBorder, z as fmtDuration } from "./node-output-fuk0NV0V.js";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
- import { Maximize2, Minimize2, Plus, X } from "lucide-react";
5
- import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlow, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState } from "@xyflow/react";
4
+ import { Expand, Maximize2, Minimize2, Plus, Shrink, X } from "lucide-react";
5
+ import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, MiniMap, Panel, Position, ReactFlow, ViewportPortal, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState, useStore } from "@xyflow/react";
6
6
  import "@xyflow/react/dist/style.css";
7
7
  //#region src/workflows/flow-graph.ts
8
8
  /**
@@ -48,7 +48,8 @@ const FLOW_GRAPH_OPTION_KEYS = [
48
48
  "compact",
49
49
  "wrap",
50
50
  "edges",
51
- "reserveEdgeInsert"
51
+ "reserveEdgeInsert",
52
+ "itemCounts"
52
53
  ];
53
54
  /**
54
55
  * Accept either an options object or a bare `nodeState` map as the second arg, so
@@ -88,13 +89,17 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
88
89
  compact,
89
90
  ...options.wrap ? { wrap: true } : {},
90
91
  ...options.edges ? { edges: options.edges } : {},
91
- ...options.reserveEdgeInsert ? { reserveEdgeInsert: true } : {}
92
+ ...options.reserveEdgeInsert ? { reserveEdgeInsert: true } : {},
93
+ ...options.itemCounts ? { itemCounts: options.itemCounts } : {}
92
94
  });
93
95
  const isLR = direction === "LR";
94
96
  const sourcePosition = isLR ? Position.Right : Position.Bottom;
95
97
  const targetPosition = isLR ? Position.Left : Position.Top;
96
98
  return {
97
99
  error: graph.error,
100
+ compact,
101
+ direction,
102
+ ...graph.spacing ? { spacing: graph.spacing } : {},
98
103
  nodes: graph.nodes.map((n) => ({
99
104
  id: n.id,
100
105
  type: "wfNode",
@@ -287,6 +292,520 @@ function clearOfNodeBoxes(point, boxes, direction, half) {
287
292
  return Math.abs(up) <= Math.abs(down) ? up : down;
288
293
  }
289
294
  //#endregion
295
+ //#region src/workflows/elk-layout.ts
296
+ /** One ELK instance per page. `null` once loading failed, so a host without
297
+ * the peer pays for one failed import, not one per render. */
298
+ let elkInstance;
299
+ function loadElk() {
300
+ elkInstance ??= import("elkjs/lib/elk.bundled.js").then((mod) => {
301
+ const Elk = mod.default;
302
+ return new Elk();
303
+ }).catch(() => null);
304
+ return elkInstance;
305
+ }
306
+ /**
307
+ * Whether the graph is worth the ELK pass: anything but a plain line. A line
308
+ * has nothing to order or route, and `model.ts` folds a long one into rows
309
+ * that read in order, which a layered layout would not improve.
310
+ */
311
+ function wantsElkLayout(graph) {
312
+ if (graph.error || graph.nodes.length < 3) return false;
313
+ const outdegree = /* @__PURE__ */ new Map();
314
+ const indegree = /* @__PURE__ */ new Map();
315
+ for (const e of graph.edges) {
316
+ if (e.data?.whenLabel !== void 0 || e.data?.backEdge) return true;
317
+ outdegree.set(e.source, (outdegree.get(e.source) ?? 0) + 1);
318
+ indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
319
+ }
320
+ for (const n of outdegree.values()) if (n > 1) return true;
321
+ for (const n of indegree.values()) if (n > 1) return true;
322
+ return false;
323
+ }
324
+ /**
325
+ * A join that runs when ANY of its inputs fails (`join: any_failed`) is a
326
+ * failure handler, not a step in the flow: the factory's `failed` node is fed
327
+ * by ten steps across every stage. Drawn as ten real edges it pulls the whole
328
+ * layout toward one corner and buries the flow under lines that only matter
329
+ * when something breaks. Its inputs are laid out and drawn as one caption on
330
+ * the node instead; the node itself still lays out and opens like any other.
331
+ */
332
+ function isFailureSink(graph, nodeId) {
333
+ return graph.nodes.find((n) => n.id === nodeId)?.data.config?.join === "any_failed" && !graph.edges.some((e) => e.source === nodeId);
334
+ }
335
+ /**
336
+ * ELK options for one flow direction. Every value here was chosen against the
337
+ * shapes the catalogue ships (a 22-step factory with two loops and a ten-way
338
+ * failure join, a four-lane review) rather than taken as defaults:
339
+ * - model order breaks ties, so a layer reads in the order the author wrote
340
+ * the steps unless reordering removes a crossing;
341
+ * - cycle breaking follows the same order, so the edge drawn as the loop's
342
+ * return is the one that points back up the definition, as the model's own
343
+ * back-edge classification marks it;
344
+ * - orthogonal routing keeps every edge on horizontal and vertical runs, and
345
+ * a guard chip sits on its edge's longest run (see the renderer). Chips are
346
+ * NOT given to ELK as label boxes: a label is laid out as a node of its own
347
+ * layer, which on the factory doubled the layer count and the width.
348
+ */
349
+ function elkLayoutOptions(direction, spacing) {
350
+ return {
351
+ "elk.algorithm": "layered",
352
+ "elk.direction": direction === "LR" ? "RIGHT" : "DOWN",
353
+ "elk.edgeRouting": "ORTHOGONAL",
354
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
355
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
356
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
357
+ "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
358
+ "elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
359
+ "elk.layered.feedbackEdges": "true",
360
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(Math.max(spacing.rank, 40)),
361
+ "elk.spacing.nodeNode": String(Math.max(spacing.cross, 20)),
362
+ "elk.spacing.edgeNode": "14",
363
+ "elk.spacing.edgeEdge": "10",
364
+ "elk.layered.spacing.edgeNodeBetweenLayers": "14",
365
+ "elk.layered.spacing.edgeEdgeBetweenLayers": "10",
366
+ "elk.padding": "[top=0,left=0,bottom=0,right=0]",
367
+ "elk.separateConnectedComponents": "false"
368
+ };
369
+ }
370
+ /**
371
+ * Cut a long layered graph into rows stacked toward `aspect` (width / height
372
+ * of the canvas it is drawn in), so a fourteen-layer pipeline uses the
373
+ * panel's height instead of shrinking to fit one strip. ELK picks the cuts
374
+ * and routes the edges that cross them.
375
+ */
376
+ function wrapOptions(aspect) {
377
+ return {
378
+ "elk.aspectRatio": String(aspect),
379
+ "elk.layered.wrapping.strategy": "MULTI_EDGE",
380
+ "elk.layered.wrapping.additionalEdgeSpacing": "16",
381
+ "elk.layered.wrapping.correctionFactor": "1.0"
382
+ };
383
+ }
384
+ /** The graph in ELK's terms: every node at its authoritative size, and every
385
+ * edge except a failure sink's inputs. */
386
+ /**
387
+ * Where a node's edges attach, relative to its box: the handle the card
388
+ * draws. A compact node's box is much bigger than its tile (the name sits
389
+ * under it), so routing to the box would stop every edge short of the tile it
390
+ * points at. One port per side, at the handle, makes ELK route to exactly
391
+ * where the card draws its connection point.
392
+ */
393
+ function handleOffset(width, height, direction, compact) {
394
+ if (direction === "LR") {
395
+ const y = compact ? 38 : height / 2;
396
+ return {
397
+ inX: 0,
398
+ inY: y,
399
+ outX: width,
400
+ outY: y
401
+ };
402
+ }
403
+ const x = compact ? 38 : width / 2;
404
+ return {
405
+ inX: x,
406
+ inY: 0,
407
+ outX: x,
408
+ outY: height
409
+ };
410
+ }
411
+ /** Fewer items than this stay a column of lanes; more become a grid. */
412
+ const GRID_MIN_ITEMS = 5;
413
+ const GRID_GAP = 16;
414
+ function itemGrids(graph, direction) {
415
+ const grids = [];
416
+ for (const source of graph.nodes) {
417
+ if (source.data.kind !== "foreach") continue;
418
+ const prefix = `${source.id}-b`;
419
+ const members = graph.nodes.filter((n) => n.id.startsWith(prefix) && /^\d+$/.test(n.id.slice(prefix.length)));
420
+ if (members.length < GRID_MIN_ITEMS) continue;
421
+ const ids = new Set(members.map((n) => n.id));
422
+ const targets = [...new Set(graph.edges.filter((e) => ids.has(e.source) && !ids.has(e.target)).map((e) => e.target))];
423
+ const cellW = Math.max(...members.map((n) => n.width ?? 0));
424
+ const cellH = Math.max(...members.map((n) => n.height ?? 0));
425
+ const cols = direction === "LR" ? Math.ceil(Math.sqrt(members.length * 1.6)) : 2;
426
+ const rows = Math.ceil(members.length / cols);
427
+ grids.push({
428
+ source: source.id,
429
+ members: members.map((n) => n.id),
430
+ targets,
431
+ cols,
432
+ cellW,
433
+ cellH,
434
+ width: cols * cellW + (cols - 1) * GRID_GAP + 2 * FRAME_PAD,
435
+ height: rows * cellH + (rows - 1) * GRID_GAP + 2 * FRAME_PAD + FRAME_CAPTION
436
+ });
437
+ }
438
+ return grids;
439
+ }
440
+ const gridNodeId = (source) => `grid:${source}`;
441
+ function toElkGraph(graph, direction, wrapAspect, compact = false, foldFailureInputs = true) {
442
+ const spacing = graph.spacing ?? {
443
+ rank: 72,
444
+ cross: 24
445
+ };
446
+ const sinks = foldedSinks(graph, foldFailureInputs);
447
+ const grids = itemGrids(graph, direction);
448
+ const inGrid = new Set(grids.flatMap((g) => g.members));
449
+ const isLR = direction === "LR";
450
+ const nodeChild = (n) => {
451
+ const width = n.width ?? 0;
452
+ const height = n.height ?? 0;
453
+ const at = handleOffset(width, height, direction, compact);
454
+ return {
455
+ id: n.id,
456
+ width,
457
+ height,
458
+ layoutOptions: {
459
+ "elk.portConstraints": "FIXED_POS",
460
+ ...sinks.has(n.id) ? { "elk.layered.layering.layerConstraint": "LAST" } : {}
461
+ },
462
+ ports: [{
463
+ id: `${n.id}:in`,
464
+ x: at.inX,
465
+ y: at.inY,
466
+ width: 0,
467
+ height: 0
468
+ }, {
469
+ id: `${n.id}:out`,
470
+ x: at.outX,
471
+ y: at.outY,
472
+ width: 0,
473
+ height: 0
474
+ }]
475
+ };
476
+ };
477
+ const gridChild = (g) => ({
478
+ id: gridNodeId(g.source),
479
+ width: g.width,
480
+ height: g.height,
481
+ layoutOptions: { "elk.portConstraints": "FIXED_POS" },
482
+ ports: [{
483
+ id: `${gridNodeId(g.source)}:in`,
484
+ x: isLR ? 0 : g.width / 2,
485
+ y: isLR ? g.height / 2 : 0,
486
+ width: 0,
487
+ height: 0
488
+ }, {
489
+ id: `${gridNodeId(g.source)}:out`,
490
+ x: isLR ? g.width : g.width / 2,
491
+ y: isLR ? g.height / 2 : g.height,
492
+ width: 0,
493
+ height: 0
494
+ }]
495
+ });
496
+ return {
497
+ id: "root",
498
+ layoutOptions: {
499
+ ...elkLayoutOptions(direction, spacing),
500
+ ...wrapAspect !== void 0 ? wrapOptions(wrapAspect) : {}
501
+ },
502
+ children: graph.nodes.flatMap((n) => {
503
+ const grid = grids.find((g) => g.members[0] === n.id);
504
+ if (grid) return [gridChild(grid)];
505
+ if (inGrid.has(n.id)) return [];
506
+ return [nodeChild(n)];
507
+ }),
508
+ edges: [...graph.edges.filter((e) => !sinks.has(e.target) && !inGrid.has(e.source) && !inGrid.has(e.target)).map((e) => ({
509
+ id: e.id,
510
+ sources: [`${e.source}:out`],
511
+ targets: [`${e.target}:in`]
512
+ })), ...grids.flatMap((g) => [{
513
+ id: `${gridNodeId(g.source)}:enter`,
514
+ sources: [`${g.source}:out`],
515
+ targets: [`${gridNodeId(g.source)}:in`]
516
+ }, ...g.targets.filter((t) => !sinks.has(t)).map((t) => ({
517
+ id: `${gridNodeId(g.source)}:leave:${t}`,
518
+ sources: [`${gridNodeId(g.source)}:out`],
519
+ targets: [`${t}:in`]
520
+ }))])]
521
+ };
522
+ }
523
+ /** The middle of a polyline's longest straight run: the one place on a routed
524
+ * edge with room for a chip, and distinct per edge even when several leave
525
+ * the same node together. */
526
+ function longestRunMidpoint(points) {
527
+ let best = 0;
528
+ let at = {
529
+ x: points[0].x,
530
+ y: points[0].y
531
+ };
532
+ for (let i = 1; i < points.length; i += 1) {
533
+ const a = points[i - 1];
534
+ const b = points[i];
535
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
536
+ if (len > best) {
537
+ best = len;
538
+ at = {
539
+ x: (a.x + b.x) / 2,
540
+ y: (a.y + b.y) / 2
541
+ };
542
+ }
543
+ }
544
+ return at;
545
+ }
546
+ /** Flatten an ELK edge's sections into one polyline. */
547
+ function edgePoints(edge) {
548
+ const points = [];
549
+ for (const section of edge.sections ?? []) points.push(section.startPoint, ...section.bendPoints ?? [], section.endPoint);
550
+ return points;
551
+ }
552
+ /**
553
+ * Apply an ELK result to the graph: new positions, a route on every edge, and
554
+ * frames around parallel fan-outs. Pure, so the mapping is checkable without
555
+ * running ELK. Returns null when the result does not cover the graph (a node
556
+ * ELK did not place), which the caller treats as "keep the first layout".
557
+ */
558
+ /** The failure handlers whose inputs this layout folds. */
559
+ function foldedSinks(graph, fold) {
560
+ if (!fold) return /* @__PURE__ */ new Set();
561
+ return new Set(graph.nodes.filter((n) => isFailureSink(graph, n.id)).map((n) => n.id));
562
+ }
563
+ function applyElkLayout(graph, laid, direction, foldFailureInputs = true) {
564
+ const placed = /* @__PURE__ */ new Map();
565
+ for (const child of laid.children ?? []) {
566
+ if (child.x === void 0 || child.y === void 0) return null;
567
+ placed.set(child.id, {
568
+ x: child.x,
569
+ y: child.y
570
+ });
571
+ }
572
+ const grids = itemGrids(graph, direction);
573
+ const gridFrames = [];
574
+ for (const g of grids) {
575
+ const box = placed.get(gridNodeId(g.source));
576
+ if (!box) return null;
577
+ g.members.forEach((id, k) => {
578
+ const col = k % g.cols;
579
+ const row = Math.floor(k / g.cols);
580
+ placed.set(id, {
581
+ x: box.x + FRAME_PAD + col * (g.cellW + GRID_GAP),
582
+ y: box.y + FRAME_PAD + FRAME_CAPTION + row * (g.cellH + GRID_GAP)
583
+ });
584
+ });
585
+ const concurrency = graph.nodes.find((n) => n.id === g.source)?.data.config?.maxConcurrency;
586
+ const tileInset = graph.compact && graph.direction === "LR" ? (n) => ((n.width ?? 0) - 76) / 2 : () => 0;
587
+ const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
588
+ const connectors = (laid.edges ?? []).filter((e) => e.id.startsWith(`${gridNodeId(g.source)}:`)).map((e) => {
589
+ const points = edgePoints(e).map((p) => ({
590
+ x: p.x,
591
+ y: p.y
592
+ }));
593
+ const from = nodeById.get(e.sources[0].replace(/:out$/, ""));
594
+ const to = nodeById.get(e.targets[0].replace(/:in$/, ""));
595
+ if (points.length >= 2 && from) points[0].x -= tileInset(from);
596
+ if (points.length >= 2 && to) points[points.length - 1].x += tileInset(to);
597
+ return { points };
598
+ }).filter((c) => c.points.length >= 2);
599
+ gridFrames.push({
600
+ id: `frame:${gridNodeId(g.source)}`,
601
+ x: box.x,
602
+ y: box.y,
603
+ width: g.width,
604
+ height: g.height,
605
+ lanes: g.members.length,
606
+ agents: g.members.filter((id) => graph.nodes.find((n) => n.id === id)?.data.kind === "agent.run").length,
607
+ forEach: typeof concurrency === "number" ? { concurrency } : {},
608
+ connectors
609
+ });
610
+ }
611
+ const inGrid = new Set(grids.flatMap((g) => g.members));
612
+ if (graph.nodes.some((n) => !placed.has(n.id))) return null;
613
+ const routed = /* @__PURE__ */ new Map();
614
+ for (const e of laid.edges ?? []) routed.set(e.id, e);
615
+ const sinks = foldedSinks(graph, foldFailureInputs);
616
+ const sinkInputs = /* @__PURE__ */ new Map();
617
+ for (const e of graph.edges) if (sinks.has(e.target)) sinkInputs.set(e.target, (sinkInputs.get(e.target) ?? 0) + 1);
618
+ const nodes = graph.nodes.map((n) => {
619
+ const inputs = sinkInputs.get(n.id);
620
+ return {
621
+ ...n,
622
+ position: placed.get(n.id),
623
+ ...inputs !== void 0 ? { data: {
624
+ ...n.data,
625
+ subtitle: `If any of ${inputs} step${inputs === 1 ? "" : "s"} fails`
626
+ } } : {}
627
+ };
628
+ });
629
+ const edges = graph.edges.map((e) => {
630
+ if (inGrid.has(e.source) || inGrid.has(e.target)) return {
631
+ ...e,
632
+ hidden: true
633
+ };
634
+ if (sinkInputs.has(e.target)) return {
635
+ ...e,
636
+ hidden: true,
637
+ ...e.data ? { data: {
638
+ ...e.data,
639
+ foldedFailureInput: true
640
+ } } : {}
641
+ };
642
+ const r = routed.get(e.id);
643
+ const points = r ? edgePoints(r) : [];
644
+ if (points.length < 2) return e;
645
+ const from = placed.get(e.source);
646
+ const to = placed.get(e.target);
647
+ return {
648
+ ...e,
649
+ type: WF_EDGE_TYPE,
650
+ data: {
651
+ ...e.data ?? { kind: "spine" },
652
+ route: {
653
+ points: points.map((p) => ({
654
+ x: p.x,
655
+ y: p.y
656
+ })),
657
+ label: longestRunMidpoint(points),
658
+ from,
659
+ to
660
+ }
661
+ }
662
+ };
663
+ });
664
+ const laidOut = {
665
+ ...graph,
666
+ nodes,
667
+ edges,
668
+ engine: "elk"
669
+ };
670
+ const groups = [...gridFrames, ...parallelFrames(laidOut)];
671
+ return groups.length > 0 ? {
672
+ ...laidOut,
673
+ groups
674
+ } : laidOut;
675
+ }
676
+ /** Air between a frame and the cards inside it, and the band its caption sits
677
+ * in above them. */
678
+ const FRAME_PAD = 10;
679
+ const FRAME_CAPTION = 18;
680
+ /**
681
+ * Frames around PARALLEL fan-outs: two or more steps that start together
682
+ * because the same steps finished, over unguarded edges. A guarded fan-out is
683
+ * a choice, not parallel work, and gets no frame — its edge chips already say
684
+ * which way it goes. Each member is followed down its own lane (a single
685
+ * unguarded successor that only it feeds) so a review-then-verify lane is
686
+ * framed whole rather than just its first step.
687
+ *
688
+ * A frame that would enclose a card outside the fan-out is dropped: a frame
689
+ * claiming an unrelated step is worse than no frame.
690
+ */
691
+ function parallelFrames(graph) {
692
+ const incomingSource = (key) => key.includes("|") ? null : key;
693
+ const forward = graph.edges.filter((e) => !e.data?.backEdge && !e.hidden);
694
+ const preds = /* @__PURE__ */ new Map();
695
+ const succs = /* @__PURE__ */ new Map();
696
+ for (const e of forward) {
697
+ preds.set(e.target, [...preds.get(e.target) ?? [], e]);
698
+ succs.set(e.source, [...succs.get(e.source) ?? [], e]);
699
+ }
700
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
701
+ const siblings = /* @__PURE__ */ new Map();
702
+ for (const n of graph.nodes) {
703
+ if (triggerNodeIndex(n.id) !== null) continue;
704
+ const incoming = preds.get(n.id) ?? [];
705
+ if (incoming.length === 0) continue;
706
+ if (incoming.some((e) => e.data?.whenLabel !== void 0)) continue;
707
+ if (n.data.config && "if" in n.data.config) continue;
708
+ const key = incoming.map((e) => e.source).sort().join("|");
709
+ siblings.set(key, [...siblings.get(key) ?? [], n.id]);
710
+ }
711
+ const frames = [];
712
+ for (const [key, members] of siblings) {
713
+ if (members.length < 2) continue;
714
+ const framed = /* @__PURE__ */ new Set();
715
+ for (const start of members) {
716
+ let at = start;
717
+ framed.add(at);
718
+ for (let hop = 0; hop < graph.nodes.length; hop += 1) {
719
+ const out = succs.get(at) ?? [];
720
+ if (out.length !== 1 || out[0].data?.whenLabel !== void 0) break;
721
+ const next = out[0].target;
722
+ if ((preds.get(next) ?? []).length !== 1 || framed.has(next)) break;
723
+ const config = byId.get(next)?.data.config;
724
+ if (config && "if" in config) break;
725
+ framed.add(next);
726
+ at = next;
727
+ }
728
+ }
729
+ const boxes = [...framed].map((id) => byId.get(id)).filter(Boolean);
730
+ const x0 = Math.min(...boxes.map((n) => n.position.x)) - FRAME_PAD;
731
+ const y0 = Math.min(...boxes.map((n) => n.position.y)) - FRAME_PAD - FRAME_CAPTION;
732
+ const x1 = Math.max(...boxes.map((n) => n.position.x + (n.width ?? 0))) + FRAME_PAD;
733
+ const y1 = Math.max(...boxes.map((n) => n.position.y + (n.height ?? 0))) + FRAME_PAD;
734
+ if (graph.nodes.some((n) => {
735
+ if (framed.has(n.id)) return false;
736
+ const nx1 = n.position.x + (n.width ?? 0);
737
+ const ny1 = n.position.y + (n.height ?? 0);
738
+ return n.position.x < x1 && nx1 > x0 && n.position.y < y1 && ny1 > y0;
739
+ })) continue;
740
+ const source = incomingSource(key);
741
+ const sourceNode = source ? byId.get(source) : void 0;
742
+ const concurrency = sourceNode?.data.config?.maxConcurrency;
743
+ frames.push({
744
+ ...sourceNode?.data.kind === "foreach" ? { forEach: typeof concurrency === "number" ? { concurrency } : {} } : {},
745
+ id: `frame:${key}`,
746
+ x: x0,
747
+ y: y0,
748
+ width: x1 - x0,
749
+ height: y1 - y0,
750
+ lanes: members.length,
751
+ agents: boxes.filter((n) => n.data.kind === "agent.run").length
752
+ });
753
+ }
754
+ return frames;
755
+ }
756
+ /**
757
+ * Lay the graph out with ELK. Resolves to null when ELK is unavailable or the
758
+ * layout fails, and the caller keeps the synchronous layout.
759
+ */
760
+ async function layoutWithElk(graph, direction, compact, canvasAspect = DEFAULT_CANVAS_ASPECT, options = {}) {
761
+ const fold = options.foldFailureInputs ?? true;
762
+ const elk = await loadElk();
763
+ if (!elk) return null;
764
+ try {
765
+ const plain = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, void 0, compact, fold)), direction, fold);
766
+ if (!plain) return null;
767
+ const plainAspect = boundsAspect(plain);
768
+ if (direction !== "LR" || laidOutNodeCount(graph) < WRAP_MIN_NODES || plainAspect < canvasAspect * WRAP_WHEN_WIDER) return plain;
769
+ const wrapped = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, canvasAspect, compact, fold)), direction, fold);
770
+ if (!wrapped) return plain;
771
+ return fitScale(wrapped, canvasAspect) > fitScale(plain, canvasAspect) ? wrapped : plain;
772
+ } catch {
773
+ return null;
774
+ }
775
+ }
776
+ /** The aspect a graph is framed in when the host says nothing: the workflow
777
+ * page's canvas, about 1100 x 480. */
778
+ const DEFAULT_CANVAS_ASPECT = 2.2;
779
+ /** How much wider than its canvas a single band must be before wrapping is
780
+ * tried. */
781
+ const WRAP_WHEN_WIDER = 1.6;
782
+ /** The nodes ELK places: a grid of items counts once. */
783
+ function laidOutNodeCount(graph) {
784
+ const grids = itemGrids(graph, "LR");
785
+ return graph.nodes.length - grids.reduce((sum, g) => sum + g.members.length, 0) + grids.length;
786
+ }
787
+ /** Below this many nodes a graph reads better as one band: cut into rows, a
788
+ * six-step loop sends its edges across the row break for no gain in size. */
789
+ const WRAP_MIN_NODES = 12;
790
+ function bounds(graph) {
791
+ const xs = graph.nodes.flatMap((n) => [n.position.x, n.position.x + (n.width ?? 0)]);
792
+ const ys = graph.nodes.flatMap((n) => [n.position.y, n.position.y + (n.height ?? 0)]);
793
+ return {
794
+ width: Math.max(...xs) - Math.min(...xs),
795
+ height: Math.max(...ys) - Math.min(...ys)
796
+ };
797
+ }
798
+ function boundsAspect(graph) {
799
+ const { width, height } = bounds(graph);
800
+ return width / Math.max(height, 1);
801
+ }
802
+ /** The scale a graph fits a unit-height canvas of `aspect` at: bigger is a
803
+ * more readable graph. */
804
+ function fitScale(graph, aspect) {
805
+ const { width, height } = bounds(graph);
806
+ return Math.min(aspect / Math.max(width, 1), 1 / Math.max(height, 1));
807
+ }
808
+ //#endregion
290
809
  //#region src/workflows/WorkflowGraph.tsx
291
810
  /**
292
811
  * n8n-style node-graph view of a workflow, rendered from its YAML definition.
@@ -341,6 +860,22 @@ function fitZoomFloor(compact) {
341
860
  return compact ? .55 : .35;
342
861
  }
343
862
  /**
863
+ * The floor for a graph ELK laid out. ELK wraps a long graph into rows sized
864
+ * for the canvas, so its fit is the whole workflow at once — the shape Drew
865
+ * asked to see — and names stay legible through semantic zoom
866
+ * (useCompactTitleScale). Clamping it at the compact floor instead would crop
867
+ * a layout built to be seen whole.
868
+ */
869
+ const ELK_FIT_FLOOR = .28;
870
+ /**
871
+ * The fit floor a laid-out graph opens at. Only a left-to-right ELK layout
872
+ * is built to be seen whole; a top-to-bottom one is a phone's column, read by
873
+ * panning down it from the top, so it keeps the readable compact floor.
874
+ */
875
+ function elkFloor(graph, direction) {
876
+ return graph.engine === "elk" && direction === "LR" ? ELK_FIT_FLOOR : void 0;
877
+ }
878
+ /**
344
879
  * The rectangle the LAID-OUT nodes occupy, in flow coordinates.
345
880
  *
346
881
  * Read straight off the layouter's own position + size rather than through
@@ -378,6 +913,39 @@ function layoutBounds(nodes) {
378
913
  height: maxY - minY
379
914
  };
380
915
  }
916
+ /** What the camera frames: the nodes, and the parallel frames drawn around
917
+ * them, whose captions sit above the first lane. */
918
+ function graphBounds(graph) {
919
+ const nodes = layoutBounds(graph.nodes);
920
+ if (!graph.groups || graph.groups.length === 0) return nodes;
921
+ const x0 = Math.min(nodes.x, ...graph.groups.map((g) => g.x));
922
+ const y0 = Math.min(nodes.y, ...graph.groups.map((g) => g.y));
923
+ const x1 = Math.max(nodes.x + nodes.width, ...graph.groups.map((g) => g.x + g.width));
924
+ const y1 = Math.max(nodes.y + nodes.height, ...graph.groups.map((g) => g.y + g.height));
925
+ return {
926
+ x: x0,
927
+ y: y0,
928
+ width: x1 - x0,
929
+ height: y1 - y0
930
+ };
931
+ }
932
+ /**
933
+ * The camera a graph opens at: {@link framingViewport} over the graph and its
934
+ * frames, at the floor its layout calls for. A top-to-bottom column wider
935
+ * than a phone is read down its spine, so the camera centres on the entry
936
+ * step rather than on the column's left edge, where only loop returns run.
937
+ */
938
+ function openingViewport(graph, width, height, compact, direction) {
939
+ const bounds = graphBounds(graph);
940
+ const view = framingViewport(bounds, width, height, compact, elkFloor(graph, direction));
941
+ const entry = graph.nodes[0];
942
+ if (direction !== "TB" || !entry || bounds.width * view.zoom <= width) return view;
943
+ const entryCenter = entry.position.x + (entry.width ?? 0) / 2;
944
+ return {
945
+ ...view,
946
+ x: width / 2 - entryCenter * view.zoom
947
+ };
948
+ }
381
949
  /**
382
950
  * The camera that frames `bounds` inside a `width`×`height` canvas.
383
951
  *
@@ -397,8 +965,8 @@ function layoutBounds(nodes) {
397
965
  * is already centered with slack on both sides, so `max` returns it untouched,
398
966
  * and nothing here has to re-derive React Flow's own reading of `padding`.
399
967
  */
400
- function framingViewport(bounds, width, height, compact) {
401
- const fit = getViewportForBounds(bounds, width, height, fitZoomFloor(compact), fitZoomCeiling(compact), FIT_VIEW.padding);
968
+ function framingViewport(bounds, width, height, compact, floor = fitZoomFloor(compact)) {
969
+ const fit = getViewportForBounds(bounds, width, height, floor, fitZoomCeiling(compact), FIT_VIEW.padding);
402
970
  /** Slide `offset` just far enough that the bounds' leading edge is on canvas. */
403
971
  const anchored = (offset, origin) => offset + Math.max(0, -(offset + origin * fit.zoom));
404
972
  return {
@@ -657,6 +1225,7 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
657
1225
  className: handleClass,
658
1226
  style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
659
1227
  })] });
1228
+ const titleScale = useCompactTitleScale(compact);
660
1229
  if (compact) return /* @__PURE__ */ jsxs("div", {
661
1230
  className: `relative flex h-full w-full ${isLR ? "flex-col items-center" : "flex-row items-center"}`,
662
1231
  children: [
@@ -712,10 +1281,14 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
712
1281
  children: [
713
1282
  /* @__PURE__ */ jsx("div", {
714
1283
  className: "truncate font-semibold text-[12px] text-foreground leading-tight",
1284
+ style: titleScale > 1 ? {
1285
+ fontSize: 12 * titleScale,
1286
+ ...isLR ? { marginInline: -16 } : {}
1287
+ } : void 0,
715
1288
  title: d.title,
716
1289
  children: d.title
717
1290
  }),
718
- subtitle && /* @__PURE__ */ jsx("div", {
1291
+ subtitle && titleScale <= SUBTITLE_MAX_SCALE && /* @__PURE__ */ jsx("div", {
719
1292
  className: "truncate text-[11px] text-muted-foreground leading-tight",
720
1293
  title: subtitle,
721
1294
  children: subtitle
@@ -944,6 +1517,263 @@ function releasedOnCanvas(at, frame) {
944
1517
  * then never revisited.
945
1518
  */
946
1519
  const NodeBoxesContext = createContext([]);
1520
+ /** A node's current top-left, read from the store so an edge notices its end
1521
+ * moving (a drag, a tween). Null while the node is not in the store. */
1522
+ function useInternalNodePosition(id) {
1523
+ return useStore(useCallback((state) => {
1524
+ const node = state.nodeLookup.get(id);
1525
+ return node ? node.position : null;
1526
+ }, [id]), (a, b) => a === b || a !== null && b !== null && samePoint(a, b));
1527
+ }
1528
+ function samePoint(a, b) {
1529
+ return Math.abs(a.x - b.x) < .5 && Math.abs(a.y - b.y) < .5;
1530
+ }
1531
+ /** Corner radius of a routed edge's bends, capped by the shorter of the two
1532
+ * segments that meet there so a tight jog never overshoots. */
1533
+ const ROUTE_CORNER = 8;
1534
+ /** An orthogonal polyline as an SVG path with rounded bends. */
1535
+ function roundedPolyline(points) {
1536
+ if (points.length === 0) return "";
1537
+ let d = `M ${points[0].x} ${points[0].y}`;
1538
+ for (let i = 1; i < points.length - 1; i += 1) {
1539
+ const prev = points[i - 1];
1540
+ const at = points[i];
1541
+ const next = points[i + 1];
1542
+ const inLen = Math.hypot(at.x - prev.x, at.y - prev.y);
1543
+ const outLen = Math.hypot(next.x - at.x, next.y - at.y);
1544
+ const r = Math.min(ROUTE_CORNER, inLen / 2, outLen / 2);
1545
+ if (r < .5) {
1546
+ d += ` L ${at.x} ${at.y}`;
1547
+ continue;
1548
+ }
1549
+ const before = {
1550
+ x: at.x - (at.x - prev.x) / inLen * r,
1551
+ y: at.y - (at.y - prev.y) / inLen * r
1552
+ };
1553
+ const after = {
1554
+ x: at.x + (next.x - at.x) / outLen * r,
1555
+ y: at.y + (next.y - at.y) / outLen * r
1556
+ };
1557
+ d += ` L ${before.x} ${before.y} Q ${at.x} ${at.y} ${after.x} ${after.y}`;
1558
+ }
1559
+ const last = points[points.length - 1];
1560
+ return `${d} L ${last.x} ${last.y}`;
1561
+ }
1562
+ /** The point halfway along a polyline, where an unlabelled route's insert
1563
+ * control or problem chip sits. */
1564
+ function polylineMidpoint(points) {
1565
+ const lengths = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
1566
+ let remaining = lengths.reduce((sum, len) => sum + len, 0) / 2;
1567
+ for (let i = 0; i < lengths.length; i += 1) {
1568
+ if (remaining <= lengths[i] && lengths[i] > 0) {
1569
+ const t = remaining / lengths[i];
1570
+ return {
1571
+ x: points[i].x + (points[i + 1].x - points[i].x) * t,
1572
+ y: points[i].y + (points[i + 1].y - points[i].y) * t
1573
+ };
1574
+ }
1575
+ remaining -= lengths[i];
1576
+ }
1577
+ return points[points.length - 1] ?? {
1578
+ x: 0,
1579
+ y: 0
1580
+ };
1581
+ }
1582
+ /**
1583
+ * The graph `buildFlowGraph` laid out, re-laid by ELK when it branches,
1584
+ * guards or loops (see elk-layout.ts).
1585
+ *
1586
+ * While ELK works on a new structure the graph ON SCREEN stays, so a relayout
1587
+ * is one move from what the reader sees to the new layout — never a detour
1588
+ * through an older laid-out graph. Before anything has been shown the canvas
1589
+ * stays hidden (`ready` false) rather than flashing a layout it is about to
1590
+ * replace, but only until {@link REVEAL_DEADLINE_MS}: ELK that is slow, absent
1591
+ * or failing then leaves the synchronous layout on screen, so no failure can
1592
+ * leave the canvas blank. `ready` gates the first reveal only.
1593
+ */
1594
+ function useElkLayout(base, direction, compact, canvasAspect, foldFailureInputs) {
1595
+ const wanted = wantsElkLayout(base);
1596
+ const [laid, setLaid] = useState(null);
1597
+ const [deadlinePassed, setDeadlinePassed] = useState(false);
1598
+ const shownRef = useRef(null);
1599
+ useEffect(() => {
1600
+ if (!wanted || canvasAspect === null) return;
1601
+ let live = true;
1602
+ layoutWithElk(base, direction, compact, canvasAspect, { foldFailureInputs }).then((graph) => {
1603
+ if (live) setLaid({
1604
+ base,
1605
+ graph: graph ?? base
1606
+ });
1607
+ });
1608
+ return () => {
1609
+ live = false;
1610
+ };
1611
+ }, [
1612
+ base,
1613
+ direction,
1614
+ wanted,
1615
+ canvasAspect,
1616
+ foldFailureInputs
1617
+ ]);
1618
+ useEffect(() => {
1619
+ if (deadlinePassed) return;
1620
+ const timer = setTimeout(() => setDeadlinePassed(true), REVEAL_DEADLINE_MS);
1621
+ return () => clearTimeout(timer);
1622
+ }, [deadlinePassed]);
1623
+ let graph;
1624
+ let settled;
1625
+ if (!wanted) {
1626
+ graph = base;
1627
+ settled = true;
1628
+ } else if (laid?.base === base) {
1629
+ graph = laid.graph;
1630
+ settled = true;
1631
+ } else {
1632
+ graph = shownRef.current ?? base;
1633
+ settled = false;
1634
+ }
1635
+ const ready = settled || shownRef.current !== null || deadlinePassed;
1636
+ if (ready) shownRef.current = graph;
1637
+ return {
1638
+ graph,
1639
+ ready
1640
+ };
1641
+ }
1642
+ /** How long a first ELK layout may keep the canvas hidden before the
1643
+ * synchronous layout is shown instead. */
1644
+ const REVEAL_DEADLINE_MS = 600;
1645
+ /**
1646
+ * The canvas's shape, in three buckets: portrait (a phone), square, and the
1647
+ * wide panel the workflow pages use. Bucketed so a resize re-lays the graph
1648
+ * out only when its shape class changes, never on every pixel. Null until
1649
+ * the canvas has been measured.
1650
+ *
1651
+ * Returns a CALLBACK ref: the canvas element can mount after the graph does
1652
+ * (a first render with no YAML shows a placeholder instead), and a mount-time
1653
+ * effect would never see it — leaving the canvas unmeasured and ELK unrun.
1654
+ */
1655
+ function useCanvasAspect() {
1656
+ const [aspect, setAspect] = useState(null);
1657
+ const observerRef = useRef(null);
1658
+ return [aspect, useCallback((el) => {
1659
+ observerRef.current?.disconnect();
1660
+ observerRef.current = null;
1661
+ if (!el) return;
1662
+ const measure = () => {
1663
+ const { width, height } = el.getBoundingClientRect();
1664
+ if (width === 0 || height === 0) return;
1665
+ const raw = width / height;
1666
+ setAspect(raw < .9 ? .6 : raw < 1.6 ? 1.2 : 2.2);
1667
+ };
1668
+ measure();
1669
+ if (typeof ResizeObserver === "undefined") {
1670
+ setAspect((current) => current ?? 2.2);
1671
+ return;
1672
+ }
1673
+ observerRef.current = new ResizeObserver(measure);
1674
+ observerRef.current.observe(el);
1675
+ }, [])];
1676
+ }
1677
+ /** "4 agents in parallel" when each lane is one agent; "4 parallel lanes ·
1678
+ * 8 agents" when a lane is a chain (a reviewer and its verifier). */
1679
+ function parallelCaption(frame) {
1680
+ if (frame.forEach) {
1681
+ const at = frame.forEach.concurrency;
1682
+ return at !== void 0 && at < frame.lanes ? `${frame.lanes} items · ${at} at a time` : `${frame.lanes} items in parallel`;
1683
+ }
1684
+ if (frame.agents > frame.lanes) return `${frame.lanes} parallel lanes · ${frame.agents} agents`;
1685
+ return frame.agents === frame.lanes ? `${frame.lanes} agents in parallel` : `${frame.lanes} in parallel`;
1686
+ }
1687
+ /** Frames behind parallel fan-outs, drawn in flow coordinates under the
1688
+ * nodes. Pointer-transparent: a frame annotates, it is never a target. */
1689
+ function ParallelFrames({ frames }) {
1690
+ const connectors = frames.flatMap((f) => f.connectors ?? []);
1691
+ return /* @__PURE__ */ jsxs(ViewportPortal, { children: [connectors.length > 0 && /* @__PURE__ */ jsxs("svg", {
1692
+ "aria-hidden": true,
1693
+ className: "pointer-events-none absolute top-0 left-0 overflow-visible",
1694
+ style: { zIndex: -1 },
1695
+ width: 1,
1696
+ height: 1,
1697
+ children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("marker", {
1698
+ id: "wf-grid-arrow",
1699
+ viewBox: "0 0 10 10",
1700
+ refX: "9",
1701
+ refY: "5",
1702
+ markerWidth: "7",
1703
+ markerHeight: "7",
1704
+ orient: "auto-start-reverse",
1705
+ children: /* @__PURE__ */ jsx("path", {
1706
+ d: "M 0 0 L 10 5 L 0 10 z",
1707
+ fill: GRID_CONNECTOR_COLOR
1708
+ })
1709
+ }) }), connectors.map((c, k) => /* @__PURE__ */ jsx("path", {
1710
+ d: roundedPolyline(c.points),
1711
+ fill: "none",
1712
+ stroke: GRID_CONNECTOR_COLOR,
1713
+ strokeWidth: 1.75,
1714
+ markerEnd: "url(#wf-grid-arrow)"
1715
+ }, k))]
1716
+ }), frames.map((frame) => /* @__PURE__ */ jsx("div", {
1717
+ "data-testid": "wf-parallel-frame",
1718
+ className: "pointer-events-none absolute rounded-xl border border-primary/30 border-dashed bg-primary/[0.04]",
1719
+ style: {
1720
+ transform: `translate(${frame.x}px, ${frame.y}px)`,
1721
+ width: frame.width,
1722
+ height: frame.height,
1723
+ zIndex: -1
1724
+ },
1725
+ children: /* @__PURE__ */ jsx("span", {
1726
+ className: "absolute top-1 left-2.5 font-medium text-[10px] text-primary/80 uppercase tracking-wide",
1727
+ children: parallelCaption(frame)
1728
+ })
1729
+ }, frame.id))] });
1730
+ }
1731
+ /** Below this zoom a compact node's name is too small to read, so it grows to
1732
+ * hold a readable size on screen; the tile, the layout and the edges keep
1733
+ * their scale. Capped so a name never outgrows the corridor beside it. */
1734
+ const TITLE_READABLE_ZOOM = .75;
1735
+ const TITLE_MAX_SCALE = 2.4;
1736
+ /** Past this the subtitle is dropped: two grown lines would crowd the next
1737
+ * row's tiles, and the title is the one a reader scans for. */
1738
+ const SUBTITLE_MAX_SCALE = 1.25;
1739
+ /**
1740
+ * Semantic zoom for a compact node's name. A whole 20-step factory framed in
1741
+ * one canvas sits near zoom 0.35, where 12px names render at 4px — the graph's
1742
+ * SHAPE reads but no step does. Quantised to tenths so a zoom gesture
1743
+ * re-renders a node a handful of times, not on every frame.
1744
+ */
1745
+ function useCompactTitleScale(enabled) {
1746
+ return useStore(useCallback((state) => {
1747
+ if (!enabled) return 1;
1748
+ const zoom = state.transform[2];
1749
+ if (zoom >= TITLE_READABLE_ZOOM) return 1;
1750
+ return Math.min(TITLE_MAX_SCALE, Math.round(TITLE_READABLE_ZOOM / zoom * 10) / 10);
1751
+ }, [enabled]));
1752
+ }
1753
+ /** The highest leaf index per fan-out in a run state, as a stable string.
1754
+ * The model applies counts to `foreach` steps only; a `parallel` already
1755
+ * draws every branch it declares. Null when no step reported more than one
1756
+ * item. */
1757
+ function foreachItemCountsKey(nodeState) {
1758
+ if (!nodeState) return null;
1759
+ const counts = {};
1760
+ for (const id of Object.keys(nodeState)) {
1761
+ const match = /^a(\d+)-b(\d+)$/.exec(id);
1762
+ if (!match) continue;
1763
+ const action = Number(match[1]);
1764
+ counts[action] = Math.max(counts[action] ?? 0, Number(match[2]) + 1);
1765
+ }
1766
+ const wide = Object.entries(counts).filter(([, n]) => n > 1);
1767
+ return wide.length > 0 ? JSON.stringify(Object.fromEntries(wide)) : null;
1768
+ }
1769
+ /** The resting edge colour (edgeColor with no run state), for the lines a
1770
+ * grid frame draws itself. */
1771
+ const GRID_CONNECTOR_COLOR = "color-mix(in srgb, hsl(var(--muted-foreground)) 38%, transparent)";
1772
+ /** Past this many nodes the full canvas carries a minimap, so a reader zoomed
1773
+ * into one corner of a large graph can see where they are in it. Below it
1774
+ * the wrapped layout fits the canvas whole, and a minimap would only cover
1775
+ * part of the graph it summarises. */
1776
+ const MINIMAP_MIN_NODES = 40;
947
1777
  /** Chip styling shared by the guard summary and the cycle badge, so an edge's
948
1778
  * two possible annotations read as one pair rather than two designs. */
949
1779
  const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-[1px] text-[10px] leading-tight backdrop-blur";
@@ -959,7 +1789,11 @@ const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-
959
1789
  */
960
1790
  function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
961
1791
  const insertStep = useContext(EdgeInsertContext);
962
- const [path, labelX, labelY] = getSmoothStepPath({
1792
+ const sourceNode = useInternalNodePosition(source);
1793
+ const targetNode = useInternalNodePosition(target);
1794
+ const route = data?.route;
1795
+ const onRoute = route !== void 0 && sourceNode !== null && targetNode !== null && samePoint(sourceNode, route.from) && samePoint(targetNode, route.to);
1796
+ const [stepPath, stepLabelX, stepLabelY] = getSmoothStepPath({
963
1797
  sourceX,
964
1798
  sourceY,
965
1799
  sourcePosition,
@@ -967,6 +1801,24 @@ function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY
967
1801
  targetY,
968
1802
  targetPosition
969
1803
  });
1804
+ const routePoints = onRoute ? [
1805
+ {
1806
+ x: sourceX,
1807
+ y: sourceY
1808
+ },
1809
+ ...route.points.slice(1, -1),
1810
+ {
1811
+ x: targetX,
1812
+ y: targetY
1813
+ }
1814
+ ] : [];
1815
+ const path = onRoute ? roundedPolyline(routePoints) : stepPath;
1816
+ const labelAt = onRoute ? route.label ?? polylineMidpoint(routePoints) : {
1817
+ x: stepLabelX,
1818
+ y: stepLabelY
1819
+ };
1820
+ const labelX = labelAt.x;
1821
+ const labelY = labelAt.y;
970
1822
  const backEdge = data?.backEdge === true;
971
1823
  const whenLabel = data?.whenLabel;
972
1824
  const visits = data?.maxNodeVisits;
@@ -1109,7 +1961,7 @@ function decorateAuthoringEdges(edges, byEdge, insertable, hasRunOverlay) {
1109
1961
  };
1110
1962
  });
1111
1963
  }
1112
- 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 }) {
1964
+ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defaultCompact, onCompactChange, className, wrap = false, nodeState, edges: declaredEdges, maxNodeVisits, selectedNodeId, onNodeClick, onEdgeConnect, onEdgeDelete, onEdgeClick, onEdgeInsert, onNodeInsert, onTriggerAdd, onTriggerDelete, problems }) {
1113
1965
  const editable = onEdgeConnect !== void 0;
1114
1966
  const insertOnEdge = editable ? onEdgeInsert : void 0;
1115
1967
  const insertArmed = insertOnEdge !== void 0;
@@ -1130,20 +1982,26 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1130
1982
  * `userCompact`, which is the only thing React may read for that.
1131
1983
  */
1132
1984
  const nextDensityFrom = useRef(userCompact);
1985
+ const [fullscreen, setFullscreen] = useState(false);
1133
1986
  const compact = isPreview || userCompact;
1134
1987
  const [displayCompact, setDisplayCompact] = useState(compact);
1135
1988
  const rfRef = useRef(null);
1136
1989
  const wrapperRef = useRef(null);
1990
+ const [canvasAspect, measureCanvas] = useCanvasAspect();
1991
+ const direction = directionProp ?? (canvasAspect !== null && canvasAspect < .9 ? "TB" : "LR");
1137
1992
  const framedRef = useRef(false);
1138
1993
  const [framingCue, setFramingCue] = useState(0);
1139
1994
  const hasRunOverlay = nodeState !== void 0;
1140
- const structural = useMemo(() => buildFlowGraph(yaml, {
1995
+ const itemCountsKey = useMemo(() => foreachItemCountsKey(nodeState), [nodeState]);
1996
+ const itemCounts = useMemo(() => itemCountsKey ? JSON.parse(itemCountsKey) : void 0, [itemCountsKey]);
1997
+ const { graph: structural, ready: laidOut } = useElkLayout(useMemo(() => buildFlowGraph(yaml, {
1141
1998
  nodeState: hasRunOverlay ? {} : void 0,
1142
1999
  direction,
1143
2000
  compact,
1144
2001
  wrap,
1145
2002
  reserveEdgeInsert: insertArmed,
1146
- ...declaredEdges ? { edges: declaredEdges } : {}
2003
+ ...declaredEdges ? { edges: declaredEdges } : {},
2004
+ ...itemCounts ? { itemCounts } : {}
1147
2005
  }), [
1148
2006
  yaml,
1149
2007
  hasRunOverlay,
@@ -1151,8 +2009,11 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1151
2009
  compact,
1152
2010
  wrap,
1153
2011
  insertArmed,
1154
- declaredEdges
1155
- ]);
2012
+ declaredEdges,
2013
+ itemCounts
2014
+ ]), direction, compact, canvasAspect, !editable);
2015
+ const shownCompact = structural.compact ?? compact;
2016
+ const shownDirection = structural.direction ?? direction;
1156
2017
  const colorMode = useColorMode(wrapperRef, !structural.error && structural.nodes.length > 0);
1157
2018
  const nodeBoxes = useMemo(() => structural.nodes.map((node) => ({
1158
2019
  x: node.position.x,
@@ -1164,6 +2025,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1164
2025
  const styledEdges = useMemo(() => {
1165
2026
  return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
1166
2027
  ...e,
2028
+ ...e.hidden && e.data?.foldedFailureInput && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
1167
2029
  deletable: editable ? isEditableEdge(e) : false
1168
2030
  }));
1169
2031
  }, [
@@ -1193,7 +2055,10 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1193
2055
  setNodes
1194
2056
  ]);
1195
2057
  const didInitialSeedRef = useRef(false);
2058
+ const revealedRef = useRef(laidOut);
1196
2059
  useEffect(() => {
2060
+ const firstReveal = laidOut && !revealedRef.current;
2061
+ revealedRef.current = laidOut;
1197
2062
  const withRunState = (nodes) => {
1198
2063
  const state = nodeStateRef.current;
1199
2064
  if (!state) return nodes;
@@ -1208,27 +2073,28 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1208
2073
  if (!didInitialSeedRef.current) {
1209
2074
  didInitialSeedRef.current = true;
1210
2075
  setNodes(withRunState(structural.nodes));
1211
- setDisplayCompact(compact);
2076
+ setDisplayCompact(shownCompact);
1212
2077
  return;
1213
2078
  }
1214
2079
  const inst = rfRef.current;
1215
2080
  const frame = wrapperRef.current?.getBoundingClientRect();
1216
2081
  if (!inst || !frame || frame.width === 0 || frame.height === 0) {
1217
2082
  setNodes(withRunState(structural.nodes));
1218
- setDisplayCompact(compact);
2083
+ setDisplayCompact(shownCompact);
1219
2084
  return;
1220
2085
  }
1221
- const endViewport = framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact);
2086
+ const endViewport = openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection);
1222
2087
  framedRef.current = true;
1223
2088
  const prev = nodesRef.current;
1224
2089
  const prevById = new Map(prev.map((n) => [n.id, n]));
1225
- if (!(prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id))) || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
2090
+ const sameNodeSet = prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id));
2091
+ if (firstReveal || !sameNodeSet || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
1226
2092
  setNodes(withRunState(structural.nodes));
1227
- setDisplayCompact(compact);
2093
+ setDisplayCompact(shownCompact);
1228
2094
  inst.setViewport(endViewport);
1229
2095
  return;
1230
2096
  }
1231
- const flipTimer = setTimeout(() => setDisplayCompact(compact), 220 * (compact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
2097
+ const flipTimer = setTimeout(() => setDisplayCompact(shownCompact), 220 * (shownCompact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
1232
2098
  const startViewport = inst.getViewport();
1233
2099
  const startedAt = performance.now();
1234
2100
  let raf = requestAnimationFrame(function step(now) {
@@ -1267,9 +2133,26 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1267
2133
  };
1268
2134
  }, [
1269
2135
  structural,
1270
- compact,
1271
- setNodes
2136
+ shownCompact,
2137
+ setNodes,
2138
+ laidOut,
2139
+ shownDirection
1272
2140
  ]);
2141
+ const structuralRef = useRef(structural);
2142
+ structuralRef.current = structural;
2143
+ const compactRef = useRef(shownCompact);
2144
+ compactRef.current = shownCompact;
2145
+ const directionRef = useRef(shownDirection);
2146
+ directionRef.current = shownDirection;
2147
+ const reframe = useCallback(() => {
2148
+ requestAnimationFrame(() => {
2149
+ const inst = rfRef.current;
2150
+ const frame = wrapperRef.current?.getBoundingClientRect();
2151
+ if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
2152
+ const graph = structuralRef.current;
2153
+ inst.setViewport(openingViewport(graph, frame.width, frame.height, compactRef.current, directionRef.current));
2154
+ });
2155
+ }, []);
1273
2156
  /**
1274
2157
  * Own the INITIAL frame once the instance and canvas have a size. React
1275
2158
  * Flow's one-time `fitView` can run after this effect and overwrite the
@@ -1281,12 +2164,13 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1281
2164
  const inst = rfRef.current;
1282
2165
  const frame = wrapperRef.current?.getBoundingClientRect();
1283
2166
  if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
1284
- inst.setViewport(framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact));
2167
+ inst.setViewport(openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection));
1285
2168
  framedRef.current = true;
1286
2169
  }, [
1287
2170
  framingCue,
1288
2171
  structural,
1289
- compact
2172
+ shownCompact,
2173
+ shownDirection
1290
2174
  ]);
1291
2175
  useEffect(() => {
1292
2176
  const el = wrapperRef.current;
@@ -1301,6 +2185,20 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1301
2185
  observer.observe(el);
1302
2186
  return () => observer.disconnect();
1303
2187
  }, []);
2188
+ const fullscreenSeen = useRef(fullscreen);
2189
+ useEffect(() => {
2190
+ if (fullscreenSeen.current === fullscreen) return;
2191
+ fullscreenSeen.current = fullscreen;
2192
+ reframe();
2193
+ if (!fullscreen) return;
2194
+ const onKey = (event) => {
2195
+ if (event.key !== "Escape" || event.defaultPrevented) return;
2196
+ if (document.querySelector("[aria-modal=\"true\"]")) return;
2197
+ setFullscreen(false);
2198
+ };
2199
+ window.addEventListener("keydown", onKey);
2200
+ return () => window.removeEventListener("keydown", onKey);
2201
+ }, [fullscreen, reframe]);
1304
2202
  const handleNodeClick = useCallback((_event, node) => {
1305
2203
  onNodeClick?.(node.id, node.data);
1306
2204
  }, [onNodeClick]);
@@ -1363,7 +2261,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1363
2261
  children: structural.error ?? "Nothing to show"
1364
2262
  });
1365
2263
  return /* @__PURE__ */ jsx(DirectionContext.Provider, {
1366
- value: direction,
2264
+ value: shownDirection,
1367
2265
  children: /* @__PURE__ */ jsx(DensityContext.Provider, {
1368
2266
  value: displayCompact,
1369
2267
  children: /* @__PURE__ */ jsx(RunModeContext.Provider, {
@@ -1381,8 +2279,12 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1381
2279
  children: /* @__PURE__ */ jsx(EdgeInsertContext.Provider, {
1382
2280
  value: insertOnEdge ?? null,
1383
2281
  children: /* @__PURE__ */ jsx("div", {
1384
- ref: wrapperRef,
1385
- className: `wf-graph ${className ?? ""}`,
2282
+ ref: (el) => {
2283
+ wrapperRef.current = el;
2284
+ measureCanvas(el);
2285
+ },
2286
+ className: `wf-graph ${laidOut ? "" : "opacity-0"} ${fullscreen ? "!fixed !inset-3 !z-40 !h-auto !w-auto overflow-hidden rounded-xl border border-border bg-background shadow-2xl" : className ?? ""}`,
2287
+ "data-layout": laidOut ? "ready" : "pending",
1386
2288
  children: /* @__PURE__ */ jsxs(ReactFlow, {
1387
2289
  nodes,
1388
2290
  edges,
@@ -1421,42 +2323,70 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1421
2323
  preventScrolling: false,
1422
2324
  minZoom: .2,
1423
2325
  maxZoom: 1.5,
1424
- children: [/* @__PURE__ */ jsx(Background, {
1425
- gap: 18,
1426
- size: 1.4,
1427
- color: "hsl(var(--muted-foreground) / 0.38)"
1428
- }), !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
1429
- showInteractive: false,
1430
- position: "bottom-right",
1431
- fitViewOptions: { padding: FIT_VIEW.padding }
1432
- }), /* @__PURE__ */ jsxs(Panel, {
1433
- position: "top-left",
1434
- className: "flex items-center gap-1",
1435
- children: [addTrigger && /* @__PURE__ */ jsxs("button", {
1436
- type: "button",
1437
- "data-testid": "wf-trigger-add",
1438
- onClick: addTrigger,
1439
- title: "Add a trigger",
1440
- "aria-label": "Add a trigger",
1441
- 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",
1442
- children: [/* @__PURE__ */ jsx(Plus, {
1443
- size: 12,
1444
- "aria-hidden": true
1445
- }), "Trigger"]
1446
- }), /* @__PURE__ */ jsxs("button", {
1447
- type: "button",
1448
- onClick: () => {
1449
- const next = !nextDensityFrom.current;
1450
- nextDensityFrom.current = next;
1451
- setUserCompact(next);
1452
- onCompactChange?.(next);
2326
+ children: [
2327
+ /* @__PURE__ */ jsx(Background, {
2328
+ gap: 18,
2329
+ size: 1.4,
2330
+ color: "hsl(var(--muted-foreground) / 0.38)"
2331
+ }),
2332
+ structural.groups && structural.groups.length > 0 && /* @__PURE__ */ jsx(ParallelFrames, { frames: structural.groups }),
2333
+ !isPreview && structural.nodes.length > MINIMAP_MIN_NODES && /* @__PURE__ */ jsx(MiniMap, {
2334
+ pannable: true,
2335
+ zoomable: true,
2336
+ position: "bottom-left",
2337
+ style: {
2338
+ width: 168,
2339
+ height: 104
1453
2340
  },
1454
- "aria-pressed": userCompact,
1455
- title: userCompact ? "Expand nodes" : "Collapse nodes",
1456
- 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",
1457
- children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
1458
- })]
1459
- })] })]
2341
+ className: "!hidden sm:!block",
2342
+ ariaLabel: "Workflow overview"
2343
+ }),
2344
+ !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
2345
+ showInteractive: false,
2346
+ position: "bottom-right",
2347
+ fitViewOptions: { padding: FIT_VIEW.padding }
2348
+ }), /* @__PURE__ */ jsxs(Panel, {
2349
+ position: "top-left",
2350
+ className: "flex items-center gap-1",
2351
+ children: [
2352
+ addTrigger && /* @__PURE__ */ jsxs("button", {
2353
+ type: "button",
2354
+ "data-testid": "wf-trigger-add",
2355
+ onClick: addTrigger,
2356
+ title: "Add a trigger",
2357
+ "aria-label": "Add a trigger",
2358
+ 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",
2359
+ children: [/* @__PURE__ */ jsx(Plus, {
2360
+ size: 12,
2361
+ "aria-hidden": true
2362
+ }), "Trigger"]
2363
+ }),
2364
+ /* @__PURE__ */ jsxs("button", {
2365
+ type: "button",
2366
+ onClick: () => {
2367
+ const next = !nextDensityFrom.current;
2368
+ nextDensityFrom.current = next;
2369
+ setUserCompact(next);
2370
+ onCompactChange?.(next);
2371
+ },
2372
+ "aria-pressed": userCompact,
2373
+ title: userCompact ? "Expand nodes" : "Collapse nodes",
2374
+ 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",
2375
+ children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
2376
+ }),
2377
+ /* @__PURE__ */ jsxs("button", {
2378
+ type: "button",
2379
+ "data-testid": "wf-fullscreen",
2380
+ onClick: () => setFullscreen((on) => !on),
2381
+ "aria-pressed": fullscreen,
2382
+ title: fullscreen ? "Exit full screen (Esc)" : "Full screen",
2383
+ "aria-label": fullscreen ? "Exit full screen" : "Full screen",
2384
+ 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",
2385
+ children: [fullscreen ? /* @__PURE__ */ jsx(Shrink, { size: 12 }) : /* @__PURE__ */ jsx(Expand, { size: 12 }), fullscreen ? "Exit" : "Full screen"]
2386
+ })
2387
+ ]
2388
+ })] })
2389
+ ]
1460
2390
  })
1461
2391
  })
1462
2392
  })