@tangle-network/sandbox-ui 0.135.0 → 0.136.1

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;
@@ -812,12 +938,18 @@ function graphBounds(graph) {
812
938
  function openingViewport(graph, width, height, compact, direction) {
813
939
  const bounds = graphBounds(graph);
814
940
  const view = framingViewport(bounds, width, height, compact, elkFloor(graph, direction));
941
+ if (direction !== "TB") return view;
942
+ const y = bounds.height * view.zoom > height ? Math.max(view.y, 44 - bounds.y * view.zoom) : view.y;
815
943
  const entry = graph.nodes[0];
816
- if (direction !== "TB" || !entry || bounds.width * view.zoom <= width) return view;
944
+ if (!entry || bounds.width * view.zoom <= width) return {
945
+ ...view,
946
+ y
947
+ };
817
948
  const entryCenter = entry.position.x + (entry.width ?? 0) / 2;
818
949
  return {
819
950
  ...view,
820
- x: width / 2 - entryCenter * view.zoom
951
+ x: width / 2 - entryCenter * view.zoom,
952
+ y
821
953
  };
822
954
  }
823
955
  /**
@@ -1551,13 +1683,43 @@ function useCanvasAspect() {
1551
1683
  /** "4 agents in parallel" when each lane is one agent; "4 parallel lanes ·
1552
1684
  * 8 agents" when a lane is a chain (a reviewer and its verifier). */
1553
1685
  function parallelCaption(frame) {
1686
+ if (frame.forEach) {
1687
+ const at = frame.forEach.concurrency;
1688
+ return at !== void 0 && at < frame.lanes ? `${frame.lanes} items · ${at} at a time` : `${frame.lanes} items in parallel`;
1689
+ }
1554
1690
  if (frame.agents > frame.lanes) return `${frame.lanes} parallel lanes · ${frame.agents} agents`;
1555
1691
  return frame.agents === frame.lanes ? `${frame.lanes} agents in parallel` : `${frame.lanes} in parallel`;
1556
1692
  }
1557
1693
  /** Frames behind parallel fan-outs, drawn in flow coordinates under the
1558
1694
  * nodes. Pointer-transparent: a frame annotates, it is never a target. */
1559
1695
  function ParallelFrames({ frames }) {
1560
- return /* @__PURE__ */ jsx(ViewportPortal, { children: frames.map((frame) => /* @__PURE__ */ jsx("div", {
1696
+ const connectors = frames.flatMap((f) => f.connectors ?? []);
1697
+ return /* @__PURE__ */ jsxs(ViewportPortal, { children: [connectors.length > 0 && /* @__PURE__ */ jsxs("svg", {
1698
+ "aria-hidden": true,
1699
+ className: "pointer-events-none absolute top-0 left-0 overflow-visible",
1700
+ style: { zIndex: -1 },
1701
+ width: 1,
1702
+ height: 1,
1703
+ children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("marker", {
1704
+ id: "wf-grid-arrow",
1705
+ viewBox: "0 0 10 10",
1706
+ refX: "9",
1707
+ refY: "5",
1708
+ markerWidth: "7",
1709
+ markerHeight: "7",
1710
+ orient: "auto-start-reverse",
1711
+ children: /* @__PURE__ */ jsx("path", {
1712
+ d: "M 0 0 L 10 5 L 0 10 z",
1713
+ fill: GRID_CONNECTOR_COLOR
1714
+ })
1715
+ }) }), connectors.map((c, k) => /* @__PURE__ */ jsx("path", {
1716
+ d: roundedPolyline(c.points),
1717
+ fill: "none",
1718
+ stroke: GRID_CONNECTOR_COLOR,
1719
+ strokeWidth: 1.75,
1720
+ markerEnd: "url(#wf-grid-arrow)"
1721
+ }, k))]
1722
+ }), frames.map((frame) => /* @__PURE__ */ jsx("div", {
1561
1723
  "data-testid": "wf-parallel-frame",
1562
1724
  className: "pointer-events-none absolute rounded-xl border border-primary/30 border-dashed bg-primary/[0.04]",
1563
1725
  style: {
@@ -1570,7 +1732,7 @@ function ParallelFrames({ frames }) {
1570
1732
  className: "absolute top-1 left-2.5 font-medium text-[10px] text-primary/80 uppercase tracking-wide",
1571
1733
  children: parallelCaption(frame)
1572
1734
  })
1573
- }, frame.id)) });
1735
+ }, frame.id))] });
1574
1736
  }
1575
1737
  /** Below this zoom a compact node's name is too small to read, so it grows to
1576
1738
  * hold a readable size on screen; the tile, the layout and the edges keep
@@ -1594,6 +1756,25 @@ function useCompactTitleScale(enabled) {
1594
1756
  return Math.min(TITLE_MAX_SCALE, Math.round(TITLE_READABLE_ZOOM / zoom * 10) / 10);
1595
1757
  }, [enabled]));
1596
1758
  }
1759
+ /** The highest leaf index per fan-out in a run state, as a stable string.
1760
+ * The model applies counts to `foreach` steps only; a `parallel` already
1761
+ * draws every branch it declares. Null when no step reported more than one
1762
+ * item. */
1763
+ function foreachItemCountsKey(nodeState) {
1764
+ if (!nodeState) return null;
1765
+ const counts = {};
1766
+ for (const id of Object.keys(nodeState)) {
1767
+ const match = /^a(\d+)-b(\d+)$/.exec(id);
1768
+ if (!match) continue;
1769
+ const action = Number(match[1]);
1770
+ counts[action] = Math.max(counts[action] ?? 0, Number(match[2]) + 1);
1771
+ }
1772
+ const wide = Object.entries(counts).filter(([, n]) => n > 1);
1773
+ return wide.length > 0 ? JSON.stringify(Object.fromEntries(wide)) : null;
1774
+ }
1775
+ /** The resting edge colour (edgeColor with no run state), for the lines a
1776
+ * grid frame draws itself. */
1777
+ const GRID_CONNECTOR_COLOR = "color-mix(in srgb, hsl(var(--muted-foreground)) 38%, transparent)";
1597
1778
  /** Past this many nodes the full canvas carries a minimap, so a reader zoomed
1598
1779
  * into one corner of a large graph can see where they are in it. Below it
1599
1780
  * the wrapped layout fits the canvas whole, and a minimap would only cover
@@ -1817,13 +1998,16 @@ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defau
1817
1998
  const framedRef = useRef(false);
1818
1999
  const [framingCue, setFramingCue] = useState(0);
1819
2000
  const hasRunOverlay = nodeState !== void 0;
2001
+ const itemCountsKey = useMemo(() => foreachItemCountsKey(nodeState), [nodeState]);
2002
+ const itemCounts = useMemo(() => itemCountsKey ? JSON.parse(itemCountsKey) : void 0, [itemCountsKey]);
1820
2003
  const { graph: structural, ready: laidOut } = useElkLayout(useMemo(() => buildFlowGraph(yaml, {
1821
2004
  nodeState: hasRunOverlay ? {} : void 0,
1822
2005
  direction,
1823
2006
  compact,
1824
2007
  wrap,
1825
2008
  reserveEdgeInsert: insertArmed,
1826
- ...declaredEdges ? { edges: declaredEdges } : {}
2009
+ ...declaredEdges ? { edges: declaredEdges } : {},
2010
+ ...itemCounts ? { itemCounts } : {}
1827
2011
  }), [
1828
2012
  yaml,
1829
2013
  hasRunOverlay,
@@ -1831,7 +2015,8 @@ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defau
1831
2015
  compact,
1832
2016
  wrap,
1833
2017
  insertArmed,
1834
- declaredEdges
2018
+ declaredEdges,
2019
+ itemCounts
1835
2020
  ]), direction, compact, canvasAspect, !editable);
1836
2021
  const shownCompact = structural.compact ?? compact;
1837
2022
  const shownDirection = structural.direction ?? direction;
@@ -1846,7 +2031,7 @@ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defau
1846
2031
  const styledEdges = useMemo(() => {
1847
2032
  return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
1848
2033
  ...e,
1849
- ...e.hidden && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
2034
+ ...e.hidden && e.data?.foldedFailureInput && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
1850
2035
  deletable: editable ? isEditableEdge(e) : false
1851
2036
  }));
1852
2037
  }, [
@@ -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-BcuJ0kgt.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.1",
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": {