@tangle-network/sandbox-ui 0.92.0 → 0.93.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.
@@ -10,8 +10,9 @@ import {
10
10
  fmtDuration,
11
11
  fmtTokens,
12
12
  providerLabel,
13
- shortModel
14
- } from "./chunk-2NS724P4.js";
13
+ shortModel,
14
+ triggerNodeIndex
15
+ } from "./chunk-YOSNN3QU.js";
15
16
  import {
16
17
  ModelBrandStack,
17
18
  modelBrandFor
@@ -22,8 +23,11 @@ import "./chunk-CZ7CSFO4.js";
22
23
  // src/workflows/WorkflowGraph.tsx
23
24
  import {
24
25
  Background,
26
+ BaseEdge,
25
27
  Controls,
28
+ EdgeLabelRenderer,
26
29
  getNodesBounds,
30
+ getSmoothStepPath,
27
31
  getViewportForBounds,
28
32
  Handle,
29
33
  MarkerType,
@@ -47,6 +51,7 @@ import {
47
51
 
48
52
  // src/workflows/flow-graph.ts
49
53
  import { Position } from "@xyflow/react";
54
+ var WF_EDGE_TYPE = "wfEdge";
50
55
  function isNodeStateMap(arg) {
51
56
  const values = Object.values(arg);
52
57
  return values.length > 0 && values.every(
@@ -55,7 +60,7 @@ function isNodeStateMap(arg) {
55
60
  }
56
61
  function normalizeFlowGraphOptions(arg) {
57
62
  if (!arg) return {};
58
- if ("nodeState" in arg || "direction" in arg || "compact" in arg) {
63
+ if ("nodeState" in arg || "direction" in arg || "compact" in arg || "edges" in arg) {
59
64
  return arg;
60
65
  }
61
66
  return isNodeStateMap(arg) ? { nodeState: arg } : {};
@@ -68,7 +73,8 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
68
73
  const graph = buildWorkflowGraph(yaml, {
69
74
  reserveRunState: nodeState !== void 0,
70
75
  direction,
71
- compact
76
+ compact,
77
+ ...options.edges ? { edges: options.edges } : {}
72
78
  });
73
79
  const isLR = direction === "LR";
74
80
  const sourcePosition = isLR ? Position.Right : Position.Bottom;
@@ -82,6 +88,13 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
82
88
  position: n.position,
83
89
  width: n.width,
84
90
  height: n.height,
91
+ // A node is a `do` entry, and removing one is a list edit — never a
92
+ // canvas gesture. This matters most on an EDITABLE canvas, where the
93
+ // delete key is armed for edges: React Flow deletes a selected node
94
+ // together with every edge touching it, so a node left deletable would
95
+ // vanish from the canvas AND report each of its edges as removed,
96
+ // asking the host to drop declared edges nobody touched.
97
+ deletable: false,
85
98
  // Fix the DOM box to the laid-out size so the card can't grow past its
86
99
  // reserved space and overlap a neighbour (see model.ts nodeHeight).
87
100
  style: { width: n.width, height: n.height },
@@ -90,12 +103,20 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
90
103
  data: nodeState?.[n.id] ? { ...n.data, state: nodeState[n.id] } : n.data
91
104
  })
92
105
  ),
93
- edges: graph.edges.map((e) => ({
94
- id: e.id,
95
- source: e.source,
96
- target: e.target,
97
- type: "smoothstep"
98
- }))
106
+ edges: graph.edges.map((e) => {
107
+ const decorated = e.whenLabel !== void 0 || e.backEdge === true;
108
+ return {
109
+ id: e.id,
110
+ source: e.source,
111
+ target: e.target,
112
+ type: decorated ? WF_EDGE_TYPE : "smoothstep",
113
+ data: {
114
+ kind: e.kind,
115
+ ...e.whenLabel !== void 0 ? { whenLabel: e.whenLabel } : {},
116
+ ...e.backEdge === true ? { backEdge: true } : {}
117
+ }
118
+ };
119
+ })
99
120
  };
100
121
  }
101
122
  function sameRunState(a, b) {
@@ -248,10 +269,13 @@ import {
248
269
  Bell,
249
270
  Box,
250
271
  Cable,
272
+ Camera,
251
273
  Circle,
252
274
  Clock,
275
+ Code,
253
276
  GitBranch,
254
277
  Repeat,
278
+ ScanSearch,
255
279
  Sparkles,
256
280
  UserCheck,
257
281
  Webhook,
@@ -335,7 +359,11 @@ function progressFill(status) {
335
359
  }
336
360
  var KIND_ICON = {
337
361
  schedule: Clock,
362
+ // Both wake the workflow on an inbound POST, so they share the mark. A
363
+ // `provider_event` normally shows its provider's LOGO instead and reaches this
364
+ // glyph only when the connector has none.
338
365
  provider_event: Webhook,
366
+ webhook: Webhook,
339
367
  trigger: Zap,
340
368
  "agent.run": Sparkles,
341
369
  "integration.invoke": Cable,
@@ -343,7 +371,10 @@ var KIND_ICON = {
343
371
  parallel: GitBranch,
344
372
  foreach: Repeat,
345
373
  decision: UserCheck,
346
- "sandbox.spawn": Box
374
+ "sandbox.spawn": Box,
375
+ "sandbox.snapshot": Camera,
376
+ "script.run": Code,
377
+ "trace.analyze": ScanSearch
347
378
  };
348
379
  function NodeMark({
349
380
  kind,
@@ -421,6 +452,15 @@ function useColorMode() {
421
452
  }
422
453
  var DirectionContext = createContext("LR");
423
454
  var DensityContext = createContext(false);
455
+ var SelectedNodeContext = createContext(void 0);
456
+ var SELECTION_OUTLINE = {
457
+ outline: "2px solid hsl(var(--primary))",
458
+ outlineOffset: 2
459
+ };
460
+ var ConnectableContext = createContext(false);
461
+ function isEditableEdge(edge) {
462
+ return edge.data?.kind !== "fork" && triggerNodeIndex(edge.source) === null;
463
+ }
424
464
  function StatusFooter({
425
465
  status,
426
466
  rounds,
@@ -475,6 +515,7 @@ function StatusPill({ status }) {
475
515
  );
476
516
  }
477
517
  var HANDLE_CLASS = "!h-2 !w-2 !min-w-0 !border-0 !bg-transparent opacity-0";
518
+ var HANDLE_CONNECTABLE_CLASS = "!h-2.5 !w-2.5 !min-w-0 !rounded-full !border !border-border !bg-muted-foreground/70 opacity-100 transition-opacity hover:!bg-primary";
478
519
  function compactHandleStyle(position, isLR) {
479
520
  const tileLeft = isLR ? (COMPACT_NODE_SIZE.width - COMPACT_TILE) / 2 : 0;
480
521
  const center = COMPACT_TILE / 2;
@@ -496,11 +537,14 @@ function compactHandleStyle(position, isLR) {
496
537
  return anchor(tileLeft + center, COMPACT_TILE);
497
538
  }
498
539
  }
499
- function WorkflowNode({ data }) {
540
+ function WorkflowNode({ id, data }) {
500
541
  const d = data;
501
542
  const state = d.state;
502
543
  const direction = useContext(DirectionContext);
503
544
  const compact = useContext(DensityContext);
545
+ const connectable = useContext(ConnectableContext);
546
+ const hostSelection = useContext(SelectedNodeContext);
547
+ const selected = hostSelection !== void 0 && hostSelection === id;
504
548
  const isLR = direction === "LR";
505
549
  const targetPos = isLR ? Position2.Left : Position2.Top;
506
550
  const sourcePos = isLR ? Position2.Right : Position2.Bottom;
@@ -527,13 +571,14 @@ function WorkflowNode({ data }) {
527
571
  );
528
572
  return shape.kind === "empty" ? null : { shape, tone: source.tone, label: source.label };
529
573
  })();
574
+ const handleClass = connectable ? HANDLE_CONNECTABLE_CLASS : HANDLE_CLASS;
530
575
  const handles = /* @__PURE__ */ jsxs2(Fragment, { children: [
531
576
  !d.isRoot && /* @__PURE__ */ jsx3(
532
577
  Handle,
533
578
  {
534
579
  type: "target",
535
580
  position: targetPos,
536
- className: HANDLE_CLASS,
581
+ className: handleClass,
537
582
  style: compact ? compactHandleStyle(targetPos, isLR) : void 0
538
583
  }
539
584
  ),
@@ -542,7 +587,7 @@ function WorkflowNode({ data }) {
542
587
  {
543
588
  type: "source",
544
589
  position: sourcePos,
545
- className: HANDLE_CLASS,
590
+ className: handleClass,
546
591
  style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
547
592
  }
548
593
  )
@@ -563,7 +608,10 @@ function WorkflowNode({ data }) {
563
608
  height: COMPACT_TILE,
564
609
  ...state ? statusBorder(state.status) : {
565
610
  borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))`
566
- }
611
+ },
612
+ // The TILE is the compact node (the name beside it is unboxed), so
613
+ // the selection ring belongs to it and not to the wider box.
614
+ ...selected ? SELECTION_OUTLINE : {}
567
615
  },
568
616
  children: [
569
617
  /* @__PURE__ */ jsx3("span", { className: "wf-node-body-in flex items-center justify-center", children: /* @__PURE__ */ jsx3(
@@ -624,8 +672,11 @@ function WorkflowNode({ data }) {
624
672
  "div",
625
673
  {
626
674
  className: "relative flex h-full w-full flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-colors",
627
- style: state ? statusBorder(state.status) : {
628
- borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))`
675
+ style: {
676
+ ...state ? statusBorder(state.status) : {
677
+ borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))`
678
+ },
679
+ ...selected ? SELECTION_OUTLINE : {}
629
680
  },
630
681
  children: [
631
682
  handles,
@@ -695,22 +746,88 @@ function WorkflowNode({ data }) {
695
746
  }
696
747
  );
697
748
  }
749
+ var EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-[1px] text-[10px] leading-tight backdrop-blur";
750
+ function WfEdgeRenderer({
751
+ id,
752
+ sourceX,
753
+ sourceY,
754
+ targetX,
755
+ targetY,
756
+ sourcePosition,
757
+ targetPosition,
758
+ data,
759
+ markerEnd,
760
+ style
761
+ }) {
762
+ const [path, labelX, labelY] = getSmoothStepPath({
763
+ sourceX,
764
+ sourceY,
765
+ sourcePosition,
766
+ targetX,
767
+ targetY,
768
+ targetPosition
769
+ });
770
+ const backEdge = data?.backEdge === true;
771
+ const whenLabel = data?.whenLabel;
772
+ const visits = data?.maxNodeVisits;
773
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
774
+ /* @__PURE__ */ jsx3(BaseEdge, { id, path, markerEnd, style }),
775
+ /* @__PURE__ */ jsx3(EdgeLabelRenderer, { children: /* @__PURE__ */ jsxs2(
776
+ "div",
777
+ {
778
+ className: "nodrag nopan absolute flex flex-col items-center gap-0.5",
779
+ style: {
780
+ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`
781
+ },
782
+ children: [
783
+ whenLabel && /* @__PURE__ */ jsx3(
784
+ "span",
785
+ {
786
+ title: whenLabel,
787
+ className: `${EDGE_CHIP_CLASS} max-w-40 truncate text-muted-foreground`,
788
+ children: whenLabel
789
+ }
790
+ ),
791
+ backEdge && /* @__PURE__ */ jsx3(
792
+ "span",
793
+ {
794
+ title: visits !== void 0 ? `Loops back \u2014 each node runs at most ${visits} time${visits === 1 ? "" : "s"} per run` : "Loops back to a step that has already run",
795
+ className: `${EDGE_CHIP_CLASS} shrink-0 text-foreground`,
796
+ children: visits !== void 0 ? `\u21BA \u2264${visits}` : "\u21BA"
797
+ }
798
+ )
799
+ ]
800
+ }
801
+ ) })
802
+ ] });
803
+ }
698
804
  var NODE_TYPES = { wfNode: WorkflowNode };
699
- function buildStyledEdges(base, nodeState) {
805
+ var EDGE_TYPES = { [WF_EDGE_TYPE]: WfEdgeRenderer };
806
+ function buildStyledEdges(base, nodeState, maxNodeVisits) {
700
807
  return base.map((e) => {
701
808
  const status = nodeState?.[e.target]?.status;
702
809
  const color = edgeColor(nodeState ? status : void 0);
810
+ const backEdge = e.data?.backEdge === true;
703
811
  return {
704
812
  ...e,
705
- // The active hop flows; everything else is static.
706
- animated: status === "running",
707
- style: { strokeWidth: 1.75, stroke: color },
813
+ // The active hop flows; everything else is static. A cycle-closing edge
814
+ // never flows even into a running target: it is the loop's RETURN path,
815
+ // and animating it would read as the run travelling backwards.
816
+ animated: !backEdge && status === "running",
817
+ style: {
818
+ strokeWidth: 1.75,
819
+ stroke: color,
820
+ // Dashed, so a return path is distinguishable from a forward hop before
821
+ // its badge is read — and at a zoom where the badge isn't legible.
822
+ ...backEdge ? { strokeDasharray: "6 3" } : {}
823
+ },
708
824
  markerEnd: {
709
825
  type: MarkerType.ArrowClosed,
710
826
  width: 15,
711
827
  height: 15,
712
828
  color
713
- }
829
+ },
830
+ ...backEdge && maxNodeVisits !== void 0 && e.data ? { data: { ...e.data, maxNodeVisits } } : {}
714
831
  };
715
832
  });
716
833
  }
@@ -721,28 +838,41 @@ function WorkflowGraph({
721
838
  defaultCompact = true,
722
839
  className,
723
840
  nodeState,
724
- onNodeClick
841
+ edges: declaredEdges,
842
+ maxNodeVisits,
843
+ selectedNodeId,
844
+ onNodeClick,
845
+ onEdgeConnect,
846
+ onEdgeDelete,
847
+ onEdgeClick
725
848
  }) {
849
+ const editable = onEdgeConnect !== void 0;
726
850
  const colorMode = useColorMode();
727
851
  const isPreview = variant === "preview";
728
852
  const [userCompact, setUserCompact] = useState(defaultCompact);
729
853
  const compact = isPreview || userCompact;
730
854
  const [displayCompact, setDisplayCompact] = useState(compact);
731
- const rfRef = useRef(null);
855
+ const rfRef = useRef(
856
+ null
857
+ );
732
858
  const wrapperRef = useRef(null);
733
859
  const hasRunOverlay = nodeState !== void 0;
734
860
  const structural = useMemo(
735
861
  () => buildFlowGraph(yaml, {
736
862
  nodeState: hasRunOverlay ? {} : void 0,
737
863
  direction,
738
- compact
864
+ compact,
865
+ ...declaredEdges ? { edges: declaredEdges } : {}
739
866
  }),
740
- [yaml, hasRunOverlay, direction, compact]
741
- );
742
- const styledEdges = useMemo(
743
- () => buildStyledEdges(structural.edges, nodeState),
744
- [structural.edges, nodeState]
867
+ [yaml, hasRunOverlay, direction, compact, declaredEdges]
745
868
  );
869
+ const styledEdges = useMemo(() => {
870
+ const styled = buildStyledEdges(structural.edges, nodeState, maxNodeVisits);
871
+ return styled.map((e) => ({
872
+ ...e,
873
+ deletable: editable ? isEditableEdge(e) : false
874
+ }));
875
+ }, [structural.edges, nodeState, maxNodeVisits, editable]);
746
876
  const [nodes, setNodes, onNodesChange] = useNodesState(structural.nodes);
747
877
  const [edges, setEdges, onEdgesChange] = useEdgesState(styledEdges);
748
878
  const nodesRef = useRef(nodes);
@@ -844,6 +974,20 @@ function WorkflowGraph({
844
974
  },
845
975
  [onNodeClick]
846
976
  );
977
+ const handleNodeKeyDown = useCallback(
978
+ (event) => {
979
+ if (event.key !== "Enter" && event.key !== " ") return;
980
+ if (!(event.target instanceof HTMLElement)) return;
981
+ const nodeId = event.target.closest(".react-flow__node")?.getAttribute("data-id");
982
+ if (!nodeId) return;
983
+ const node = nodesRef.current.find((n) => n.id === nodeId);
984
+ if (!node) return;
985
+ event.preventDefault();
986
+ event.stopPropagation();
987
+ onNodeClick?.(node.id, node.data);
988
+ },
989
+ [onNodeClick]
990
+ );
847
991
  if (structural.error || structural.nodes.length === 0) {
848
992
  return /* @__PURE__ */ jsx3(
849
993
  "div",
@@ -853,7 +997,7 @@ function WorkflowGraph({
853
997
  }
854
998
  );
855
999
  }
856
- return /* @__PURE__ */ jsx3(DirectionContext.Provider, { value: direction, children: /* @__PURE__ */ jsx3(DensityContext.Provider, { value: displayCompact, children: /* @__PURE__ */ jsx3("div", { ref: wrapperRef, className: `wf-graph ${className ?? ""}`, children: /* @__PURE__ */ jsxs2(
1000
+ return /* @__PURE__ */ jsx3(DirectionContext.Provider, { value: direction, children: /* @__PURE__ */ jsx3(DensityContext.Provider, { value: displayCompact, children: /* @__PURE__ */ jsx3(SelectedNodeContext.Provider, { value: selectedNodeId, children: /* @__PURE__ */ jsx3(ConnectableContext.Provider, { value: editable, children: /* @__PURE__ */ jsx3("div", { ref: wrapperRef, className: `wf-graph ${className ?? ""}`, children: /* @__PURE__ */ jsxs2(
857
1001
  ReactFlow,
858
1002
  {
859
1003
  nodes,
@@ -861,20 +1005,39 @@ function WorkflowGraph({
861
1005
  onNodesChange,
862
1006
  onEdgesChange,
863
1007
  nodeTypes: NODE_TYPES,
1008
+ edgeTypes: EDGE_TYPES,
864
1009
  colorMode,
865
1010
  onInit: (inst) => {
866
1011
  rfRef.current = inst;
867
1012
  },
868
1013
  onNodeClick: onNodeClick ? handleNodeClick : void 0,
1014
+ onKeyDown: onNodeClick ? handleNodeKeyDown : void 0,
869
1015
  fitView: true,
870
1016
  fitViewOptions: { ...FIT_VIEW, maxZoom: fitZoomCeiling(compact) },
871
1017
  proOptions: { hideAttribution: true },
872
1018
  nodesDraggable: !isPreview,
873
- nodesConnectable: false,
874
- deleteKeyCode: null,
1019
+ nodesConnectable: editable,
1020
+ deleteKeyCode: editable ? ["Backspace", "Delete"] : null,
875
1021
  edgesReconnectable: false,
876
1022
  elementsSelectable: !isPreview,
877
- edgesFocusable: false,
1023
+ edgesFocusable: editable,
1024
+ onConnect: editable ? (connection) => {
1025
+ if (connection.source && connection.target) {
1026
+ onEdgeConnect(connection.source, connection.target);
1027
+ }
1028
+ } : void 0,
1029
+ onEdgesDelete: editable ? (deleted) => {
1030
+ for (const edge of deleted) {
1031
+ if (isEditableEdge(edge)) {
1032
+ onEdgeDelete?.(edge.source, edge.target);
1033
+ }
1034
+ }
1035
+ } : void 0,
1036
+ onEdgeClick: editable ? (_event, edge) => {
1037
+ if (isEditableEdge(edge)) {
1038
+ onEdgeClick?.(edge.source, edge.target);
1039
+ }
1040
+ } : void 0,
878
1041
  zoomOnScroll: false,
879
1042
  zoomOnPinch: true,
880
1043
  zoomOnDoubleClick: !isPreview,
@@ -903,14 +1066,17 @@ function WorkflowGraph({
903
1066
  ] })
904
1067
  ]
905
1068
  }
906
- ) }) }) });
1069
+ ) }) }) }) }) });
907
1070
  }
908
1071
  export {
1072
+ ConnectableContext,
909
1073
  DensityContext,
910
1074
  DirectionContext,
911
1075
  LAYOUT_TRANSITION_MS,
1076
+ SelectedNodeContext,
912
1077
  WorkflowGraph,
913
1078
  WorkflowNode,
914
1079
  buildStyledEdges,
915
- fitZoomCeiling
1080
+ fitZoomCeiling,
1081
+ isEditableEdge
916
1082
  };
@@ -214,9 +214,26 @@ function providerLabel(provider) {
214
214
 
215
215
  // src/workflows/model.ts
216
216
  import { parse as parseYaml } from "yaml";
217
+ var TRIGGER_NODE_ID = "trigger";
218
+ var TRIGGER_NODE_ID_PATTERN = new RegExp(`^${TRIGGER_NODE_ID}:(\\d+)$`);
219
+ function triggerNodeId(index) {
220
+ return index === 0 ? TRIGGER_NODE_ID : `${TRIGGER_NODE_ID}:${index}`;
221
+ }
222
+ function triggerNodeIndex(nodeId) {
223
+ if (nodeId === TRIGGER_NODE_ID) return 0;
224
+ const match = TRIGGER_NODE_ID_PATTERN.exec(nodeId);
225
+ return match ? Number(match[1]) : null;
226
+ }
227
+ function actionNodeId(index) {
228
+ return `a${index}`;
229
+ }
230
+ function branchNodeId(actionIndex, branchIndex) {
231
+ return `${actionNodeId(actionIndex)}-b${branchIndex}`;
232
+ }
217
233
  var NODE_W = 292;
218
234
  var RANK_SEP = 72;
219
235
  var CROSS_SEP = 24;
236
+ var EDGE_LABEL_LANE = 180;
220
237
  var CARD_CHROME = 20;
221
238
  var HEADER_ROW = 34;
222
239
  var DESCRIPTION_ROW = 40;
@@ -372,6 +389,50 @@ function describeActionBase(action) {
372
389
  tone: "structural"
373
390
  };
374
391
  }
392
+ case "script.run": {
393
+ const connections = Array.isArray(cfg.connections) ? cfg.connections.filter((c) => typeof c === "string") : [];
394
+ return {
395
+ title: "Script",
396
+ kind,
397
+ // What the box is GRANTED is the thing that distinguishes one script
398
+ // step from another at a glance; the timeout is a bound, and bounds read
399
+ // in the detail view with the rest of the config.
400
+ subtitle: connections.length > 0 ? `TypeScript \xB7 ${connections.length} connection${connections.length === 1 ? "" : "s"}` : "TypeScript",
401
+ // The head of the module — the same role the prompt plays on an agent:
402
+ // the text that says what this particular step actually does.
403
+ description: describeText(cfg.source),
404
+ isRoot: false,
405
+ tone: "action"
406
+ };
407
+ }
408
+ case "sandbox.snapshot":
409
+ return {
410
+ title: "Snapshot",
411
+ kind,
412
+ // WHICH sandbox is the only thing separating one snapshot step from
413
+ // another, and it is usually a `${steps…}` reference — so the subtitle
414
+ // reads as the step whose disk is being captured.
415
+ subtitle: str(cfg.sandbox) ?? "Capture a sandbox",
416
+ isRoot: false,
417
+ tone: "action"
418
+ };
419
+ case "trace.analyze": {
420
+ const kinds = Array.isArray(cfg.kinds) ? cfg.kinds.filter((k) => typeof k === "string") : [];
421
+ return {
422
+ title: "Trace analysis",
423
+ kind,
424
+ // The analysts that run ARE the step. The model it runs them on is
425
+ // incidental here (unlike agent.run, where the model IS the identity),
426
+ // so it stays in the config for the detail view and the node keeps its
427
+ // own glyph rather than borrowing a lab's brand mark.
428
+ subtitle: kinds.length > 0 ? describeText(
429
+ kinds.map((k) => humanizeIdentifier(k, "lower")).join(", ")
430
+ ) : "Default analysts",
431
+ description: describeText(cfg.trace),
432
+ isRoot: false,
433
+ tone: "action"
434
+ };
435
+ }
375
436
  default:
376
437
  return {
377
438
  // An action kind this version doesn't model yet still gets a readable
@@ -465,6 +526,18 @@ function describeTrigger(on) {
465
526
  sch
466
527
  );
467
528
  }
529
+ if (rec.webhook !== void 0) {
530
+ return withConfig(
531
+ {
532
+ title: "Webhook",
533
+ kind: "webhook",
534
+ subtitle: "On an inbound POST",
535
+ isRoot: true,
536
+ tone: "trigger"
537
+ },
538
+ asRecord(rec.webhook)
539
+ );
540
+ }
468
541
  const [kind] = Object.keys(rec);
469
542
  return withConfig(
470
543
  {
@@ -519,10 +592,74 @@ function layoutLayers(nodes, direction, rankSep, crossSep) {
519
592
  }
520
593
  return out;
521
594
  }
595
+ function classifyBackEdges(orderedIds, edges) {
596
+ const outgoing = /* @__PURE__ */ new Map();
597
+ const hasIncoming = /* @__PURE__ */ new Set();
598
+ for (const e of edges) {
599
+ const list = outgoing.get(e.source);
600
+ if (list) list.push(e);
601
+ else outgoing.set(e.source, [e]);
602
+ hasIncoming.add(e.target);
603
+ }
604
+ const walkOrder = [
605
+ ...orderedIds.filter((id) => !hasIncoming.has(id)),
606
+ ...orderedIds.filter((id) => hasIncoming.has(id))
607
+ ];
608
+ const back = /* @__PURE__ */ new Set();
609
+ const color = new Map(orderedIds.map((id) => [id, 0]));
610
+ for (const start of walkOrder) {
611
+ if (color.get(start) !== 0) continue;
612
+ color.set(start, 1);
613
+ const stack = [{ id: start, next: 0 }];
614
+ while (stack.length > 0) {
615
+ const frame = stack[stack.length - 1];
616
+ const out = outgoing.get(frame.id);
617
+ if (!out || frame.next >= out.length) {
618
+ color.set(frame.id, 2);
619
+ stack.pop();
620
+ continue;
621
+ }
622
+ const edge = out[frame.next];
623
+ frame.next += 1;
624
+ const target = color.get(edge.target);
625
+ if (target === 1) {
626
+ back.add(edge.id);
627
+ } else if (target === 0) {
628
+ color.set(edge.target, 1);
629
+ stack.push({ id: edge.target, next: 0 });
630
+ }
631
+ }
632
+ }
633
+ return back;
634
+ }
635
+ function rankByTopology(orderedIds, edges, backEdgeIds) {
636
+ const outgoing = /* @__PURE__ */ new Map();
637
+ const indegree = new Map(orderedIds.map((id) => [id, 0]));
638
+ for (const e of edges) {
639
+ if (backEdgeIds.has(e.id)) continue;
640
+ const list = outgoing.get(e.source);
641
+ if (list) list.push(e);
642
+ else outgoing.set(e.source, [e]);
643
+ indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
644
+ }
645
+ const rank = new Map(orderedIds.map((id) => [id, 0]));
646
+ const queue = orderedIds.filter((id) => indegree.get(id) === 0);
647
+ for (let head = 0; head < queue.length; head += 1) {
648
+ const id = queue[head];
649
+ for (const e of outgoing.get(id) ?? []) {
650
+ rank.set(e.target, Math.max(rank.get(e.target) ?? 0, (rank.get(id) ?? 0) + 1));
651
+ const remaining = (indegree.get(e.target) ?? 0) - 1;
652
+ indegree.set(e.target, remaining);
653
+ if (remaining === 0) queue.push(e.target);
654
+ }
655
+ }
656
+ return rank;
657
+ }
522
658
  function buildWorkflowGraph(yaml, options) {
523
659
  const reserveRunState = options?.reserveRunState ?? false;
524
660
  const direction = options?.direction ?? "LR";
525
661
  const compact = options?.compact ?? false;
662
+ const declared = options?.edges;
526
663
  if (!yaml || yaml.trim() === "") {
527
664
  return { nodes: [], edges: [], error: "No definition" };
528
665
  }
@@ -534,7 +671,8 @@ function buildWorkflowGraph(yaml, options) {
534
671
  }
535
672
  const def = asRecord(parsed);
536
673
  const actions = Array.isArray(def.do) ? def.do : [];
537
- if (!def.on && actions.length === 0) {
674
+ const triggers = !def.on ? [] : Array.isArray(def.on) ? def.on : [def.on];
675
+ if (triggers.length === 0 && actions.length === 0) {
538
676
  return { nodes: [], edges: [], error: "Empty workflow" };
539
677
  }
540
678
  const logical = [];
@@ -547,24 +685,39 @@ function buildWorkflowGraph(yaml, options) {
547
685
  };
548
686
  logical.push({ id, data, width: dims.width, height: dims.height, rank: rank2 });
549
687
  };
550
- const addEdge = (source, target, kind) => {
551
- edges.push({ id: `${source}->${target}`, source, target, kind });
688
+ const edgeIds = /* @__PURE__ */ new Set();
689
+ const addEdge = (source, target, kind, whenLabel) => {
690
+ const id = `${source}->${target}`;
691
+ if (edgeIds.has(id)) return;
692
+ edgeIds.add(id);
693
+ edges.push({
694
+ id,
695
+ source,
696
+ target,
697
+ kind,
698
+ ...whenLabel ? { whenLabel } : {}
699
+ });
552
700
  };
553
701
  let rank = 0;
554
702
  let prevExits = [];
555
703
  const branchLeafIds = /* @__PURE__ */ new Set();
556
- if (def.on) {
557
- addNode("trigger", describeTrigger(def.on), rank, false);
558
- prevExits = ["trigger"];
704
+ const triggerIds = triggers.map((_, i) => triggerNodeId(i));
705
+ triggers.forEach((trigger, i) => {
706
+ addNode(triggerNodeId(i), describeTrigger(trigger), rank, false);
707
+ });
708
+ if (triggers.length > 0) {
709
+ prevExits = [...triggerIds];
559
710
  rank += 1;
560
711
  }
561
712
  actions.forEach((action, i) => {
562
- const id = `a${i}`;
713
+ const id = actionNodeId(i);
563
714
  const data = describeAction(action);
564
- if (i === 0 && !def.on) data.isRoot = true;
715
+ if (i === 0 && triggers.length === 0) data.isRoot = true;
565
716
  addNode(id, data, rank, reserveRunState);
566
- for (const from of prevExits) {
567
- addEdge(from, id, branchLeafIds.has(from) ? "join" : "spine");
717
+ if (!declared) {
718
+ for (const from of prevExits) {
719
+ addEdge(from, id, branchLeafIds.has(from) ? "join" : "spine");
720
+ }
568
721
  }
569
722
  const rec = asRecord(action);
570
723
  const children = "parallel" in rec ? Array.isArray(asRecord(rec.parallel).branches) ? asRecord(rec.parallel).branches : [] : "foreach" in rec ? (
@@ -575,7 +728,7 @@ function buildWorkflowGraph(yaml, options) {
575
728
  const branchRank = rank + 1;
576
729
  const branchIds = [];
577
730
  children.forEach((child, j) => {
578
- const cid = `${id}-b${j}`;
731
+ const cid = branchNodeId(i, j);
579
732
  addNode(cid, describeAction(child), branchRank, reserveRunState);
580
733
  addEdge(id, cid, "fork");
581
734
  branchIds.push(cid);
@@ -588,8 +741,43 @@ function buildWorkflowGraph(yaml, options) {
588
741
  rank += 1;
589
742
  }
590
743
  });
744
+ if (declared) {
745
+ const known = new Set(logical.map((n) => n.id));
746
+ for (const spec of declared) {
747
+ const missing = !known.has(spec.from) ? spec.from : !known.has(spec.to) ? spec.to : null;
748
+ if (missing !== null) {
749
+ return {
750
+ nodes: [],
751
+ edges: [],
752
+ error: `Declared topology names "${missing}", which this definition has no step for`
753
+ };
754
+ }
755
+ addEdge(spec.from, spec.to, "spine", spec.whenLabel);
756
+ }
757
+ const rootward = new Set(edges.map((e) => e.target));
758
+ for (const n of logical) {
759
+ if (triggerNodeIndex(n.id) !== null) continue;
760
+ if (rootward.has(n.id)) continue;
761
+ for (const triggerId of triggerIds) addEdge(triggerId, n.id, "spine");
762
+ }
763
+ const orderedIds = logical.map((n) => n.id);
764
+ const backEdgeIds = classifyBackEdges(orderedIds, edges);
765
+ for (const e of edges) {
766
+ if (backEdgeIds.has(e.id)) e.backEdge = true;
767
+ }
768
+ const rankById = rankByTopology(orderedIds, edges, backEdgeIds);
769
+ const incoming = new Set(edges.map((e) => e.target));
770
+ for (const n of logical) {
771
+ n.rank = rankById.get(n.id) ?? 0;
772
+ n.data = { ...n.data, isRoot: !incoming.has(n.id) };
773
+ }
774
+ }
775
+ const labelled = edges.some(
776
+ (e) => e.whenLabel !== void 0 || e.backEdge === true
777
+ );
778
+ const rankSep = labelled ? Math.max(geo.rankSep, EDGE_LABEL_LANE) : geo.rankSep;
591
779
  return {
592
- nodes: layoutLayers(logical, direction, geo.rankSep, geo.crossSep),
780
+ nodes: layoutLayers(logical, direction, rankSep, geo.crossSep),
593
781
  edges,
594
782
  error: null
595
783
  };
@@ -602,6 +790,11 @@ export {
602
790
  fmtTokens,
603
791
  shortModel,
604
792
  providerLabel,
793
+ TRIGGER_NODE_ID,
794
+ triggerNodeId,
795
+ triggerNodeIndex,
796
+ actionNodeId,
797
+ branchNodeId,
605
798
  COMPACT_TILE,
606
799
  COMPACT_NODE_SIZE,
607
800
  buildWorkflowGraph
package/dist/globals.css CHANGED
@@ -718,6 +718,9 @@
718
718
  .\!h-2 {
719
719
  height: calc(var(--spacing) * 2) !important;
720
720
  }
721
+ .\!h-2\.5 {
722
+ height: calc(var(--spacing) * 2.5) !important;
723
+ }
721
724
  .h-1 {
722
725
  height: var(--spacing);
723
726
  }
@@ -796,6 +799,9 @@
796
799
  .h-\[24rem\] {
797
800
  height: 24rem;
798
801
  }
802
+ .h-\[26rem\] {
803
+ height: 26rem;
804
+ }
799
805
  .h-\[28rem\] {
800
806
  height: 28rem;
801
807
  }
@@ -967,6 +973,9 @@
967
973
  .\!w-2 {
968
974
  width: calc(var(--spacing) * 2) !important;
969
975
  }
976
+ .\!w-2\.5 {
977
+ width: calc(var(--spacing) * 2.5) !important;
978
+ }
970
979
  .w-0 {
971
980
  width: 0px;
972
981
  }
@@ -1210,6 +1219,9 @@
1210
1219
  .max-w-6xl {
1211
1220
  max-width: var(--container-6xl);
1212
1221
  }
1222
+ .max-w-40 {
1223
+ max-width: calc(var(--spacing) * 40);
1224
+ }
1213
1225
  .max-w-\[14rem\] {
1214
1226
  max-width: 14rem;
1215
1227
  }
@@ -1698,6 +1710,9 @@
1698
1710
  .overscroll-x-contain {
1699
1711
  overscroll-behavior-x: contain;
1700
1712
  }
1713
+ .\!rounded-full {
1714
+ border-radius: calc(infinity * 1px) !important;
1715
+ }
1701
1716
  .rounded {
1702
1717
  border-radius: 0.25rem;
1703
1718
  }
@@ -1781,6 +1796,10 @@
1781
1796
  .rounded-bl-lg {
1782
1797
  border-bottom-left-radius: var(--radius-lg);
1783
1798
  }
1799
+ .\!border {
1800
+ border-style: var(--tw-border-style) !important;
1801
+ border-width: 1px !important;
1802
+ }
1784
1803
  .\!border-0 {
1785
1804
  border-style: var(--tw-border-style) !important;
1786
1805
  border-width: 0px !important;
@@ -1841,6 +1860,9 @@
1841
1860
  --tw-border-style: none;
1842
1861
  border-style: none;
1843
1862
  }
1863
+ .\!border-border {
1864
+ border-color: var(--color-border) !important;
1865
+ }
1844
1866
  .border-\[var\(--accent-text\)\] {
1845
1867
  border-color: var(--accent-text);
1846
1868
  }
@@ -2081,6 +2103,12 @@
2081
2103
  .border-b-primary {
2082
2104
  border-bottom-color: var(--color-primary);
2083
2105
  }
2106
+ .\!bg-muted-foreground\/70 {
2107
+ background-color: color-mix(in srgb, hsl(var(--muted-foreground)) 70%, transparent) !important;
2108
+ @supports (color: color-mix(in lab, red, red)) {
2109
+ background-color: color-mix(in oklab, var(--color-muted-foreground) 70%, transparent) !important;
2110
+ }
2111
+ }
2084
2112
  .\!bg-transparent {
2085
2113
  background-color: transparent !important;
2086
2114
  }
@@ -2300,6 +2328,12 @@
2300
2328
  background-color: color-mix(in oklab, var(--color-card) 80%, transparent);
2301
2329
  }
2302
2330
  }
2331
+ .bg-card\/90 {
2332
+ background-color: color-mix(in srgb, hsl(var(--card)) 90%, transparent);
2333
+ @supports (color: color-mix(in lab, red, red)) {
2334
+ background-color: color-mix(in oklab, var(--color-card) 90%, transparent);
2335
+ }
2336
+ }
2303
2337
  .bg-destructive {
2304
2338
  background-color: var(--color-destructive);
2305
2339
  }
@@ -3893,6 +3927,9 @@
3893
3927
  border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
3894
3928
  }
3895
3929
  }
3930
+ .hover\:\!bg-primary:hover {
3931
+ background-color: var(--color-primary) !important;
3932
+ }
3896
3933
  .hover\:bg-\[var\(--accent-surface-soft\)\]:hover {
3897
3934
  background-color: var(--accent-surface-soft);
3898
3935
  }
package/dist/styles.css CHANGED
@@ -718,6 +718,9 @@
718
718
  .\!h-2 {
719
719
  height: calc(var(--spacing) * 2) !important;
720
720
  }
721
+ .\!h-2\.5 {
722
+ height: calc(var(--spacing) * 2.5) !important;
723
+ }
721
724
  .h-1 {
722
725
  height: var(--spacing);
723
726
  }
@@ -796,6 +799,9 @@
796
799
  .h-\[24rem\] {
797
800
  height: 24rem;
798
801
  }
802
+ .h-\[26rem\] {
803
+ height: 26rem;
804
+ }
799
805
  .h-\[28rem\] {
800
806
  height: 28rem;
801
807
  }
@@ -967,6 +973,9 @@
967
973
  .\!w-2 {
968
974
  width: calc(var(--spacing) * 2) !important;
969
975
  }
976
+ .\!w-2\.5 {
977
+ width: calc(var(--spacing) * 2.5) !important;
978
+ }
970
979
  .w-0 {
971
980
  width: 0px;
972
981
  }
@@ -1210,6 +1219,9 @@
1210
1219
  .max-w-6xl {
1211
1220
  max-width: var(--container-6xl);
1212
1221
  }
1222
+ .max-w-40 {
1223
+ max-width: calc(var(--spacing) * 40);
1224
+ }
1213
1225
  .max-w-\[14rem\] {
1214
1226
  max-width: 14rem;
1215
1227
  }
@@ -1698,6 +1710,9 @@
1698
1710
  .overscroll-x-contain {
1699
1711
  overscroll-behavior-x: contain;
1700
1712
  }
1713
+ .\!rounded-full {
1714
+ border-radius: calc(infinity * 1px) !important;
1715
+ }
1701
1716
  .rounded {
1702
1717
  border-radius: 0.25rem;
1703
1718
  }
@@ -1781,6 +1796,10 @@
1781
1796
  .rounded-bl-lg {
1782
1797
  border-bottom-left-radius: var(--radius-lg);
1783
1798
  }
1799
+ .\!border {
1800
+ border-style: var(--tw-border-style) !important;
1801
+ border-width: 1px !important;
1802
+ }
1784
1803
  .\!border-0 {
1785
1804
  border-style: var(--tw-border-style) !important;
1786
1805
  border-width: 0px !important;
@@ -1841,6 +1860,9 @@
1841
1860
  --tw-border-style: none;
1842
1861
  border-style: none;
1843
1862
  }
1863
+ .\!border-border {
1864
+ border-color: var(--color-border) !important;
1865
+ }
1844
1866
  .border-\[var\(--accent-text\)\] {
1845
1867
  border-color: var(--accent-text);
1846
1868
  }
@@ -2081,6 +2103,12 @@
2081
2103
  .border-b-primary {
2082
2104
  border-bottom-color: var(--color-primary);
2083
2105
  }
2106
+ .\!bg-muted-foreground\/70 {
2107
+ background-color: color-mix(in srgb, hsl(var(--muted-foreground)) 70%, transparent) !important;
2108
+ @supports (color: color-mix(in lab, red, red)) {
2109
+ background-color: color-mix(in oklab, var(--color-muted-foreground) 70%, transparent) !important;
2110
+ }
2111
+ }
2084
2112
  .\!bg-transparent {
2085
2113
  background-color: transparent !important;
2086
2114
  }
@@ -2300,6 +2328,12 @@
2300
2328
  background-color: color-mix(in oklab, var(--color-card) 80%, transparent);
2301
2329
  }
2302
2330
  }
2331
+ .bg-card\/90 {
2332
+ background-color: color-mix(in srgb, hsl(var(--card)) 90%, transparent);
2333
+ @supports (color: color-mix(in lab, red, red)) {
2334
+ background-color: color-mix(in oklab, var(--color-card) 90%, transparent);
2335
+ }
2336
+ }
2303
2337
  .bg-destructive {
2304
2338
  background-color: var(--color-destructive);
2305
2339
  }
@@ -3893,6 +3927,9 @@
3893
3927
  border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
3894
3928
  }
3895
3929
  }
3930
+ .hover\:\!bg-primary:hover {
3931
+ background-color: var(--color-primary) !important;
3932
+ }
3896
3933
  .hover\:bg-\[var\(--accent-surface-soft\)\]:hover {
3897
3934
  background-color: var(--accent-surface-soft);
3898
3935
  }
@@ -119,14 +119,80 @@ interface WfNode {
119
119
  height: number;
120
120
  data: WfNodeData;
121
121
  }
122
+ /**
123
+ * Node ids are PUBLIC contract, not an internal detail. A host keys its live
124
+ * `nodeState` by them, points the graph's `selectedNodeId` at one, and — once it
125
+ * supplies a declared topology — names its own edge endpoints with them. They
126
+ * are bare strings, so a host that re-derives the format at the call site gets
127
+ * no type error when the format changes here; the graph simply renders without
128
+ * edges. The format is therefore written in exactly one place (these helpers)
129
+ * and read everywhere else through them.
130
+ */
131
+ /** The workflow's first (or only) trigger. */
132
+ declare const TRIGGER_NODE_ID = "trigger";
133
+ /** The node for the `index`th entry of a list-form `on:`. Entry 0 IS
134
+ * {@link TRIGGER_NODE_ID}, so a single-trigger graph — the overwhelmingly
135
+ * common one — keeps the plain id a host may already have persisted.
136
+ *
137
+ * `index` is a position in the definition's `on:` list: a non-negative
138
+ * integer. Anything else formats an id no node bears, which a declared
139
+ * topology then rejects by name ("…names "trigger:NaN", which this definition
140
+ * has no step for") — reported there rather than thrown from here, because
141
+ * {@link buildWorkflowGraph} calls this and must never throw. */
142
+ declare function triggerNodeId(index: number): string;
143
+ /** The `on:` entry a trigger node stands for, or null when the id names
144
+ * anything else — so a host can tell a trigger from an action without
145
+ * matching the id format itself. */
146
+ declare function triggerNodeIndex(nodeId: string): number | null;
147
+ /** The node for the `do` entry at `index`. */
148
+ declare function actionNodeId(index: number): string;
149
+ /** The node for the `branchIndex`th fan-out leaf of the `do` entry at
150
+ * `actionIndex` — a `parallel` branch or a `foreach` template. */
151
+ declare function branchNodeId(actionIndex: number, branchIndex: number): string;
122
152
  type WfEdgeKind = "spine" | "fork" | "join";
123
153
  interface WfEdge {
124
154
  id: string;
125
155
  source: string;
126
156
  target: string;
127
157
  /** Spine = trigger→action→action; fork = fan-out into a branch leaf; join =
128
- * a branch leaf reconverging onto the next spine node. Drives edge styling. */
158
+ * a branch leaf reconverging onto the next spine node. Drives edge styling.
159
+ *
160
+ * A DECLARED edge ({@link BuildWorkflowGraphOptions.edges}) is a `spine`
161
+ * edge: it is the flow. Fork edges survive a declared topology unchanged (a
162
+ * branch leaf is this module's own node, which no declared spec addresses);
163
+ * join edges do not exist under one, because what follows a fan-out is then
164
+ * declared rather than inferred from list position. */
129
165
  kind: WfEdgeKind;
166
+ /** Short, already-human summary of the edge's guard, when it carries one.
167
+ * Supplied by {@link WfEdgeSpec.whenLabel} — this module never interprets a
168
+ * condition, it only places the label its host wrote. */
169
+ whenLabel?: string;
170
+ /** True when this edge closes a cycle: it points back at a node that is
171
+ * already on the path reaching it. Rendered distinctly (dashed, with the
172
+ * visit budget) because such an edge is the one that can run a node twice. */
173
+ backEdge?: boolean;
174
+ }
175
+ /**
176
+ * One edge of a DECLARED topology — the caller's answer to "what actually
177
+ * connects to what", replacing the positional spine this module would otherwise
178
+ * infer from `do`-list order.
179
+ *
180
+ * Endpoints are node ids from THIS graph, so name them with {@link actionNodeId}
181
+ * / {@link branchNodeId} / {@link TRIGGER_NODE_ID} rather than by hand. Edges
182
+ * into a node that has none are how a root is identified: every trigger node
183
+ * gets an edge to every root, so the caller declares only the topology it owns
184
+ * and never has to restate what the trigger connects to.
185
+ *
186
+ * The guard arrives PRE-SUMMARIZED (`whenLabel`). A condition's schema belongs
187
+ * to the system that compiles and evaluates it — this library renders graphs and
188
+ * has no business owning a second, drifting interpretation of one. The same
189
+ * summary the host writes here is then the one it can show elsewhere (a skipped
190
+ * step's row), which is what keeps the two readings identical.
191
+ */
192
+ interface WfEdgeSpec {
193
+ from: string;
194
+ to: string;
195
+ whenLabel?: string;
130
196
  }
131
197
  interface WfGraph {
132
198
  nodes: WfNode[];
@@ -151,12 +217,29 @@ interface BuildWorkflowGraphOptions {
151
217
  /** Collapse every node to the fixed icon-tile size, and pitch the layers for
152
218
  * it. Defaults to `false` (the full, expanded card). */
153
219
  compact?: boolean;
220
+ /**
221
+ * The graph's DECLARED topology. Omit — the default — and edges are inferred
222
+ * from `do`-list order: a linear spine, which is exactly right for a workflow
223
+ * that runs as one, and wrong for any workflow whose definition declares its
224
+ * own edges (`needs`, guards, cycles). Supply it and the inferred spine is
225
+ * replaced wholesale by these edges, the layout is re-ranked to the shape they
226
+ * describe (so a diamond reads as a diamond rather than a chain drawn over
227
+ * one), and cycle-closing edges are marked {@link WfEdge.backEdge}.
228
+ *
229
+ * An edge naming a node this graph has no slot for is an ERROR
230
+ * ({@link WfGraph.error}), never a quiet fall back to the positional spine:
231
+ * the two disagreeing means the topology and the definition came from
232
+ * different places, and a graph that draws edges the run will not take is
233
+ * worse than one that says it cannot be drawn.
234
+ */
235
+ edges?: readonly WfEdgeSpec[];
154
236
  }
155
237
  /** Build a positioned graph from a workflow YAML string. Never throws —
156
238
  * malformed YAML or an empty definition returns an `error` the UI can fall
157
239
  * back on (e.g. show the raw YAML while authoring). `reserveRunState` leaves
158
240
  * room for the rows live run state adds (see {@link nodeHeight}); `direction`
159
- * picks the flow axis (default "LR"); `compact` collapses nodes to icon tiles. */
241
+ * picks the flow axis (default "LR"); `compact` collapses nodes to icon tiles;
242
+ * `edges` replaces the inferred positional spine with a declared topology. */
160
243
  declare function buildWorkflowGraph(yaml: string, options?: BuildWorkflowGraphOptions): WfGraph;
161
244
 
162
245
  interface WorkflowGraphProps {
@@ -187,11 +270,46 @@ interface WorkflowGraphProps {
187
270
  * fresh record each update — e.g. from a poll/SSE tick — satisfies this.
188
271
  */
189
272
  nodeState?: Record<string, WfNodeState>;
273
+ /**
274
+ * The graph's DECLARED topology, replacing the spine inferred from `do`-list
275
+ * order — see {@link WfEdgeSpec}. Name endpoints with the exported id helpers
276
+ * (`actionNodeId`, `branchNodeId`, `TRIGGER_NODE_ID`).
277
+ *
278
+ * Immutability contract, as for `nodeState`: the layout memo keys on this
279
+ * array's reference, so pass a stable one (a `useMemo`, or a value derived
280
+ * once per fetch) rather than a fresh literal each render.
281
+ */
282
+ edges?: readonly WfEdgeSpec[];
283
+ /** Per-node visit budget for a cyclic graph, shown on cycle-closing edges.
284
+ * Meaningless without `edges` (an inferred spine cannot loop). */
285
+ maxNodeVisits?: number;
286
+ /** The node to ring as selected — e.g. the one whose detail panel is open.
287
+ * Selection is the host's state; the graph only reflects it. */
288
+ selectedNodeId?: string;
190
289
  /** Click handler for a node (e.g. open a detail drawer). Absent ⇒ nodes are
191
290
  * non-interactive on click. */
192
291
  onNodeClick?: (nodeId: string, data: WfNodeData) => void;
292
+ /**
293
+ * Editing gestures. Supplying `onEdgeConnect` turns the canvas from a diagram
294
+ * into an EDITOR: node handles become visible and draggable, an edge can be
295
+ * selected and removed with Delete/Backspace, and clicking one asks to edit
296
+ * its guard. Omit all three — the default — and the graph stays the read-only
297
+ * visualisation it has always been.
298
+ *
299
+ * Every callback speaks node ids (`actionNodeId`, `branchNodeId`), never
300
+ * positions: this component reports the gesture, and turning it into a
301
+ * definition edit is the host's job — it owns the YAML, and only it knows
302
+ * what a `needs` row is. Fan-out and trigger edges never fire any of these
303
+ * ({@link isEditableEdge}), because neither is a row in anyone's topology.
304
+ *
305
+ * The canvas holds no pending state: an accepted edit comes back as new
306
+ * `yaml` + `edges`, and a rejected one simply never arrives.
307
+ */
308
+ onEdgeConnect?: (sourceId: string, targetId: string) => void;
309
+ onEdgeDelete?: (sourceId: string, targetId: string) => void;
310
+ onEdgeClick?: (sourceId: string, targetId: string) => void;
193
311
  }
194
312
 
195
313
  declare function WorkflowGraph(props: WorkflowGraphProps): React.JSX.Element;
196
314
 
197
- export { type BuildWorkflowGraphOptions, type WfEdge, type WfGraph, type WfNode, type WfNodeData, type WfNodeState, type WfNodeStatus, type WfNodeTone, WorkflowGraph as WorkflowGraphLazy, type WorkflowGraphProps, buildWorkflowGraph };
315
+ export { type BuildWorkflowGraphOptions, TRIGGER_NODE_ID, type WfEdge, type WfEdgeKind, type WfEdgeSpec, type WfGraph, type WfNode, type WfNodeData, type WfNodeState, type WfNodeStatus, type WfNodeTone, WorkflowGraph as WorkflowGraphLazy, type WorkflowGraphProps, actionNodeId, branchNodeId, buildWorkflowGraph, triggerNodeId, triggerNodeIndex };
package/dist/workflows.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import {
2
- buildWorkflowGraph
3
- } from "./chunk-2NS724P4.js";
2
+ TRIGGER_NODE_ID,
3
+ actionNodeId,
4
+ branchNodeId,
5
+ buildWorkflowGraph,
6
+ triggerNodeId,
7
+ triggerNodeIndex
8
+ } from "./chunk-YOSNN3QU.js";
4
9
  import {
5
10
  ModelBrandStack,
6
11
  modelBrandFor
@@ -31,7 +36,7 @@ function retryImport(factory, retries = 2, delayMs = 350) {
31
36
  function lazyWorkflowGraph() {
32
37
  return lazy(
33
38
  () => retryImport(
34
- () => import("./WorkflowGraph-GUX7XN7Y.js").then((m) => ({ default: m.WorkflowGraph }))
39
+ () => import("./WorkflowGraph-YII62JEW.js").then((m) => ({ default: m.WorkflowGraph }))
35
40
  )
36
41
  );
37
42
  }
@@ -103,7 +108,12 @@ function WorkflowGraph(props) {
103
108
  }
104
109
  export {
105
110
  ModelBrandStack,
111
+ TRIGGER_NODE_ID,
106
112
  WorkflowGraph as WorkflowGraphLazy,
113
+ actionNodeId,
114
+ branchNodeId,
107
115
  buildWorkflowGraph,
108
- modelBrandFor
116
+ modelBrandFor,
117
+ triggerNodeId,
118
+ triggerNodeIndex
109
119
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/sandbox-ui",
3
- "version": "0.92.0",
3
+ "version": "0.93.1",
4
4
  "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components",
5
5
  "repository": {
6
6
  "type": "git",