@tangle-network/sandbox-ui 0.135.0 → 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,4 +1,4 @@
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-Bs51H68g.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
4
  import { Expand, Maximize2, Minimize2, Plus, Shrink, X } from "lucide-react";
@@ -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,7 +89,8 @@ 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;
@@ -406,50 +408,116 @@ function handleOffset(width, height, direction, compact) {
406
408
  outY: height
407
409
  };
408
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}`;
409
441
  function toElkGraph(graph, direction, wrapAspect, compact = false, foldFailureInputs = true) {
410
442
  const spacing = graph.spacing ?? {
411
443
  rank: 72,
412
444
  cross: 24
413
445
  };
414
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
+ });
415
496
  return {
416
497
  id: "root",
417
498
  layoutOptions: {
418
499
  ...elkLayoutOptions(direction, spacing),
419
500
  ...wrapAspect !== void 0 ? wrapOptions(wrapAspect) : {}
420
501
  },
421
- children: graph.nodes.map((n) => {
422
- const width = n.width ?? 0;
423
- const height = n.height ?? 0;
424
- const at = handleOffset(width, height, direction, compact);
425
- return {
426
- id: n.id,
427
- width,
428
- height,
429
- layoutOptions: {
430
- "elk.portConstraints": "FIXED_POS",
431
- ...sinks.has(n.id) ? { "elk.layered.layering.layerConstraint": "LAST" } : {}
432
- },
433
- ports: [{
434
- id: `${n.id}:in`,
435
- x: at.inX,
436
- y: at.inY,
437
- width: 0,
438
- height: 0
439
- }, {
440
- id: `${n.id}:out`,
441
- x: at.outX,
442
- y: at.outY,
443
- width: 0,
444
- height: 0
445
- }]
446
- };
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)];
447
507
  }),
448
- edges: graph.edges.filter((e) => !sinks.has(e.target)).map((e) => ({
508
+ edges: [...graph.edges.filter((e) => !sinks.has(e.target) && !inGrid.has(e.source) && !inGrid.has(e.target)).map((e) => ({
449
509
  id: e.id,
450
510
  sources: [`${e.source}:out`],
451
511
  targets: [`${e.target}:in`]
452
- }))
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
+ }))])]
453
521
  };
454
522
  }
455
523
  /** The middle of a polyline's longest straight run: the one place on a routed
@@ -492,7 +560,7 @@ function foldedSinks(graph, fold) {
492
560
  if (!fold) return /* @__PURE__ */ new Set();
493
561
  return new Set(graph.nodes.filter((n) => isFailureSink(graph, n.id)).map((n) => n.id));
494
562
  }
495
- function applyElkLayout(graph, laid, foldFailureInputs = true) {
563
+ function applyElkLayout(graph, laid, direction, foldFailureInputs = true) {
496
564
  const placed = /* @__PURE__ */ new Map();
497
565
  for (const child of laid.children ?? []) {
498
566
  if (child.x === void 0 || child.y === void 0) return null;
@@ -501,6 +569,46 @@ function applyElkLayout(graph, laid, foldFailureInputs = true) {
501
569
  y: child.y
502
570
  });
503
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));
504
612
  if (graph.nodes.some((n) => !placed.has(n.id))) return null;
505
613
  const routed = /* @__PURE__ */ new Map();
506
614
  for (const e of laid.edges ?? []) routed.set(e.id, e);
@@ -519,10 +627,18 @@ function applyElkLayout(graph, laid, foldFailureInputs = true) {
519
627
  };
520
628
  });
521
629
  const edges = graph.edges.map((e) => {
522
- if (sinkInputs.has(e.target)) return {
630
+ if (inGrid.has(e.source) || inGrid.has(e.target)) return {
523
631
  ...e,
524
632
  hidden: true
525
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
+ };
526
642
  const r = routed.get(e.id);
527
643
  const points = r ? edgePoints(r) : [];
528
644
  if (points.length < 2) return e;
@@ -551,7 +667,7 @@ function applyElkLayout(graph, laid, foldFailureInputs = true) {
551
667
  edges,
552
668
  engine: "elk"
553
669
  };
554
- const groups = parallelFrames(laidOut);
670
+ const groups = [...gridFrames, ...parallelFrames(laidOut)];
555
671
  return groups.length > 0 ? {
556
672
  ...laidOut,
557
673
  groups
@@ -573,6 +689,7 @@ const FRAME_CAPTION = 18;
573
689
  * claiming an unrelated step is worse than no frame.
574
690
  */
575
691
  function parallelFrames(graph) {
692
+ const incomingSource = (key) => key.includes("|") ? null : key;
576
693
  const forward = graph.edges.filter((e) => !e.data?.backEdge && !e.hidden);
577
694
  const preds = /* @__PURE__ */ new Map();
578
695
  const succs = /* @__PURE__ */ new Map();
@@ -620,7 +737,11 @@ function parallelFrames(graph) {
620
737
  const ny1 = n.position.y + (n.height ?? 0);
621
738
  return n.position.x < x1 && nx1 > x0 && n.position.y < y1 && ny1 > y0;
622
739
  })) continue;
740
+ const source = incomingSource(key);
741
+ const sourceNode = source ? byId.get(source) : void 0;
742
+ const concurrency = sourceNode?.data.config?.maxConcurrency;
623
743
  frames.push({
744
+ ...sourceNode?.data.kind === "foreach" ? { forEach: typeof concurrency === "number" ? { concurrency } : {} } : {},
624
745
  id: `frame:${key}`,
625
746
  x: x0,
626
747
  y: y0,
@@ -641,11 +762,11 @@ async function layoutWithElk(graph, direction, compact, canvasAspect = DEFAULT_C
641
762
  const elk = await loadElk();
642
763
  if (!elk) return null;
643
764
  try {
644
- const plain = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, void 0, compact, fold)), fold);
765
+ const plain = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, void 0, compact, fold)), direction, fold);
645
766
  if (!plain) return null;
646
767
  const plainAspect = boundsAspect(plain);
647
- if (direction !== "LR" || graph.nodes.length < WRAP_MIN_NODES || plainAspect < canvasAspect * WRAP_WHEN_WIDER) return plain;
648
- const wrapped = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, canvasAspect, compact, fold)), fold);
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);
649
770
  if (!wrapped) return plain;
650
771
  return fitScale(wrapped, canvasAspect) > fitScale(plain, canvasAspect) ? wrapped : plain;
651
772
  } catch {
@@ -658,6 +779,11 @@ const DEFAULT_CANVAS_ASPECT = 2.2;
658
779
  /** How much wider than its canvas a single band must be before wrapping is
659
780
  * tried. */
660
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
+ }
661
787
  /** Below this many nodes a graph reads better as one band: cut into rows, a
662
788
  * six-step loop sends its edges across the row break for no gain in size. */
663
789
  const WRAP_MIN_NODES = 12;
@@ -1551,13 +1677,43 @@ function useCanvasAspect() {
1551
1677
  /** "4 agents in parallel" when each lane is one agent; "4 parallel lanes ·
1552
1678
  * 8 agents" when a lane is a chain (a reviewer and its verifier). */
1553
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
+ }
1554
1684
  if (frame.agents > frame.lanes) return `${frame.lanes} parallel lanes · ${frame.agents} agents`;
1555
1685
  return frame.agents === frame.lanes ? `${frame.lanes} agents in parallel` : `${frame.lanes} in parallel`;
1556
1686
  }
1557
1687
  /** Frames behind parallel fan-outs, drawn in flow coordinates under the
1558
1688
  * nodes. Pointer-transparent: a frame annotates, it is never a target. */
1559
1689
  function ParallelFrames({ frames }) {
1560
- return /* @__PURE__ */ jsx(ViewportPortal, { children: frames.map((frame) => /* @__PURE__ */ jsx("div", {
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", {
1561
1717
  "data-testid": "wf-parallel-frame",
1562
1718
  className: "pointer-events-none absolute rounded-xl border border-primary/30 border-dashed bg-primary/[0.04]",
1563
1719
  style: {
@@ -1570,7 +1726,7 @@ function ParallelFrames({ frames }) {
1570
1726
  className: "absolute top-1 left-2.5 font-medium text-[10px] text-primary/80 uppercase tracking-wide",
1571
1727
  children: parallelCaption(frame)
1572
1728
  })
1573
- }, frame.id)) });
1729
+ }, frame.id))] });
1574
1730
  }
1575
1731
  /** Below this zoom a compact node's name is too small to read, so it grows to
1576
1732
  * hold a readable size on screen; the tile, the layout and the edges keep
@@ -1594,6 +1750,25 @@ function useCompactTitleScale(enabled) {
1594
1750
  return Math.min(TITLE_MAX_SCALE, Math.round(TITLE_READABLE_ZOOM / zoom * 10) / 10);
1595
1751
  }, [enabled]));
1596
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)";
1597
1772
  /** Past this many nodes the full canvas carries a minimap, so a reader zoomed
1598
1773
  * into one corner of a large graph can see where they are in it. Below it
1599
1774
  * the wrapped layout fits the canvas whole, and a minimap would only cover
@@ -1817,13 +1992,16 @@ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defau
1817
1992
  const framedRef = useRef(false);
1818
1993
  const [framingCue, setFramingCue] = useState(0);
1819
1994
  const hasRunOverlay = nodeState !== void 0;
1995
+ const itemCountsKey = useMemo(() => foreachItemCountsKey(nodeState), [nodeState]);
1996
+ const itemCounts = useMemo(() => itemCountsKey ? JSON.parse(itemCountsKey) : void 0, [itemCountsKey]);
1820
1997
  const { graph: structural, ready: laidOut } = useElkLayout(useMemo(() => buildFlowGraph(yaml, {
1821
1998
  nodeState: hasRunOverlay ? {} : void 0,
1822
1999
  direction,
1823
2000
  compact,
1824
2001
  wrap,
1825
2002
  reserveEdgeInsert: insertArmed,
1826
- ...declaredEdges ? { edges: declaredEdges } : {}
2003
+ ...declaredEdges ? { edges: declaredEdges } : {},
2004
+ ...itemCounts ? { itemCounts } : {}
1827
2005
  }), [
1828
2006
  yaml,
1829
2007
  hasRunOverlay,
@@ -1831,7 +2009,8 @@ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defau
1831
2009
  compact,
1832
2010
  wrap,
1833
2011
  insertArmed,
1834
- declaredEdges
2012
+ declaredEdges,
2013
+ itemCounts
1835
2014
  ]), direction, compact, canvasAspect, !editable);
1836
2015
  const shownCompact = structural.compact ?? compact;
1837
2016
  const shownDirection = structural.direction ?? direction;
@@ -1846,7 +2025,7 @@ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defau
1846
2025
  const styledEdges = useMemo(() => {
1847
2026
  return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
1848
2027
  ...e,
1849
- ...e.hidden && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
2028
+ ...e.hidden && e.data?.foldedFailureInput && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
1850
2029
  deletable: editable ? isEditableEdge(e) : false
1851
2030
  }));
1852
2031
  }, [
@@ -1245,13 +1245,18 @@ function buildWorkflowGraph(yaml, options) {
1245
1245
  addNode(id, data, rank, reserveRunState);
1246
1246
  if (!declared) for (const from of prevExits) addEdge(from, id, branchLeafIds.has(from) ? "join" : "spine");
1247
1247
  const rec = asRecord(action);
1248
- const children = "parallel" in rec ? Array.isArray(asRecord(rec.parallel).branches) ? asRecord(rec.parallel).branches : [] : "foreach" in rec ? [asRecord(rec.foreach).do].filter(Boolean) : [];
1248
+ const children = "parallel" in rec ? Array.isArray(asRecord(rec.parallel).branches) ? asRecord(rec.parallel).branches : [] : "foreach" in rec ? Array.from({ length: asRecord(rec.foreach).do ? Math.max(1, options?.itemCounts?.[i] ?? 1) : 0 }, () => asRecord(rec.foreach).do) : [];
1249
1249
  if (children.length > 0) {
1250
1250
  const branchRank = rank + 1;
1251
1251
  const branchIds = [];
1252
+ const items = "foreach" in rec && children.length > 1;
1252
1253
  children.forEach((child, j) => {
1253
1254
  const cid = branchNodeId(i, j);
1254
- addNode(cid, describeAction(child), branchRank, reserveRunState);
1255
+ const data = describeAction(child);
1256
+ addNode(cid, items ? {
1257
+ ...data,
1258
+ subtitle: `Item ${j + 1} of ${children.length}`
1259
+ } : data, branchRank, reserveRunState);
1255
1260
  addEdge(id, cid, "fork");
1256
1261
  branchIds.push(cid);
1257
1262
  branchLeafIds.add(cid);
@@ -320,6 +320,13 @@ interface BuildWorkflowGraphOptions {
320
320
  * measured afterwards. Defaults to `false`.
321
321
  */
322
322
  reserveEdgeInsert?: boolean;
323
+ /**
324
+ * How many items a settled `foreach` ran, by its action index. A foreach
325
+ * is one template step until it has run; then each item is a node of its
326
+ * own, so a fan-out over fourteen issues reads as fourteen lanes with their
327
+ * own status and cost rather than one tile with a count.
328
+ */
329
+ itemCounts?: Readonly<Record<number, number>>;
323
330
  }
324
331
  /** Build a positioned graph from a workflow YAML string. Never throws —
325
332
  * malformed YAML or an empty definition returns an `error` the UI can fall
package/dist/workflows.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { i as modelBrandFor, t as ModelBrandStack } from "./model-brand-BSr9on8C.js";
2
- import { A as triggerNodeIndex, D as buildWorkflowGraph, E as branchNodeId, F as parseActionPath, I as shortModel, N as actionPathLabel, P as humanizeIdentifier, T as actionNodeId, a as NodeMark, d as STATUS_LABEL, f as STATUS_PILL, h as StatusPill, i as KIND_ICON, k as triggerNodeId, n as classifyOutput, p as STATUS_TONE, r as condenseText, t as NodeOutputBody, u as STATUS_COLOR, w as TRIGGER_NODE_ID } from "./node-output-Bs51H68g.js";
2
+ import { A as triggerNodeIndex, D as buildWorkflowGraph, E as branchNodeId, F as parseActionPath, I as shortModel, N as actionPathLabel, P as humanizeIdentifier, T as actionNodeId, a as NodeMark, d as STATUS_LABEL, f as STATUS_PILL, h as StatusPill, i as KIND_ICON, k as triggerNodeId, n as classifyOutput, p as STATUS_TONE, r as condenseText, t as NodeOutputBody, u as STATUS_COLOR, w as TRIGGER_NODE_ID } from "./node-output-fuk0NV0V.js";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  import { Component, Suspense, lazy, useMemo, useState } from "react";
5
5
  //#region src/workflows/WorkflowGraphLazy.tsx
@@ -30,7 +30,7 @@ function retryImport(factory, retries = 2, delayMs = 350) {
30
30
  });
31
31
  }
32
32
  function lazyWorkflowGraph() {
33
- return lazy(() => retryImport(() => import("./WorkflowGraph-WgonmxDh.js").then((m) => ({ default: m.WorkflowGraph }))));
33
+ return lazy(() => retryImport(() => import("./WorkflowGraph-Cfm0Ub4F.js").then((m) => ({ default: m.WorkflowGraph }))));
34
34
  }
35
35
  var GraphErrorBoundary = class extends Component {
36
36
  state = { hasError: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/sandbox-ui",
3
- "version": "0.135.0",
3
+ "version": "0.136.0",
4
4
  "license": "MIT",
5
5
  "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components",
6
6
  "repository": {