@bian-womp/spark-workbench 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/lib/cjs/index.cjs +384 -231
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +53 -0
  4. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/contracts.d.ts +16 -0
  6. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/SelectionBoundOverlay.d.ts +10 -0
  8. package/lib/cjs/src/misc/SelectionBoundOverlay.d.ts.map +1 -0
  9. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts +4 -0
  10. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +0 -3
  13. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/context-menu/NodeContextMenu.d.ts +1 -1
  16. package/lib/cjs/src/misc/context-menu/NodeContextMenu.d.ts.map +1 -1
  17. package/lib/cjs/src/misc/load.d.ts.map +1 -1
  18. package/lib/cjs/src/misc/mapping.d.ts +7 -0
  19. package/lib/cjs/src/misc/mapping.d.ts.map +1 -1
  20. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +6 -4
  21. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  22. package/lib/cjs/src/runtime/IGraphRunner.d.ts +5 -3
  23. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  24. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts +7 -0
  25. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  26. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +4 -0
  27. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  28. package/lib/esm/index.js +385 -232
  29. package/lib/esm/index.js.map +1 -1
  30. package/lib/esm/src/core/InMemoryWorkbench.d.ts +53 -0
  31. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  32. package/lib/esm/src/core/contracts.d.ts +16 -0
  33. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  34. package/lib/esm/src/misc/SelectionBoundOverlay.d.ts +10 -0
  35. package/lib/esm/src/misc/SelectionBoundOverlay.d.ts.map +1 -0
  36. package/lib/esm/src/misc/WorkbenchCanvas.d.ts +4 -0
  37. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  38. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  39. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +0 -3
  40. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  41. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  42. package/lib/esm/src/misc/context-menu/NodeContextMenu.d.ts +1 -1
  43. package/lib/esm/src/misc/context-menu/NodeContextMenu.d.ts.map +1 -1
  44. package/lib/esm/src/misc/load.d.ts.map +1 -1
  45. package/lib/esm/src/misc/mapping.d.ts +7 -0
  46. package/lib/esm/src/misc/mapping.d.ts.map +1 -1
  47. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +6 -4
  48. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  49. package/lib/esm/src/runtime/IGraphRunner.d.ts +5 -3
  50. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  51. package/lib/esm/src/runtime/LocalGraphRunner.d.ts +7 -0
  52. package/lib/esm/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  53. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +4 -0
  54. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  55. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -146,6 +146,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
146
146
  edges: [],
147
147
  };
148
148
  this.nodeNames = {};
149
+ this.customData = {};
149
150
  this.runtimeState = null;
150
151
  this.viewport = null;
151
152
  this.historyState = undefined;
@@ -174,6 +175,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
174
175
  const filteredSizes = Object.fromEntries(Object.entries(this.sizes).filter(([id]) => defNodeIds.has(id)));
175
176
  const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
176
177
  const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
178
+ // Clean up extData for removed nodes/edges
179
+ if (this.customData.nodes) {
180
+ const filteredExtNodes = Object.fromEntries(Object.entries(this.customData.nodes).filter(([id]) => defNodeIds.has(id)));
181
+ this.customData.nodes =
182
+ Object.keys(filteredExtNodes).length > 0 ? filteredExtNodes : undefined;
183
+ }
184
+ if (this.customData.edges) {
185
+ const filteredExtEdges = Object.fromEntries(Object.entries(this.customData.edges).filter(([id]) => defEdgeIds.has(id)));
186
+ this.customData.edges =
187
+ Object.keys(filteredExtEdges).length > 0 ? filteredExtEdges : undefined;
188
+ }
177
189
  this.positions = filteredPositions;
178
190
  this.sizes = filteredSizes;
179
191
  this.selection = { nodes: filteredNodes, edges: filteredEdges };
@@ -851,6 +863,107 @@ class InMemoryWorkbench extends AbstractWorkbench {
851
863
  ...options,
852
864
  });
853
865
  }
866
+ /**
867
+ * Get custom data for a specific node.
868
+ */
869
+ getCustomNodeData(nodeId) {
870
+ return this.customData.nodes?.[nodeId];
871
+ }
872
+ /**
873
+ * Set custom data for a specific node.
874
+ */
875
+ setCustomNodeData(nodeId, data, options) {
876
+ if (!this.customData.nodes) {
877
+ this.customData.nodes = {};
878
+ }
879
+ if (data === undefined) {
880
+ delete this.customData.nodes[nodeId];
881
+ if (Object.keys(this.customData.nodes).length === 0) {
882
+ delete this.customData.nodes;
883
+ }
884
+ }
885
+ else {
886
+ this.customData.nodes[nodeId] = data;
887
+ }
888
+ this.emit("graphUiChanged", {
889
+ change: { type: "customNodeData", nodeId, data },
890
+ ...options,
891
+ });
892
+ }
893
+ /**
894
+ * Get custom data for a specific edge.
895
+ */
896
+ getCustomEdgeData(edgeId) {
897
+ return this.customData.edges?.[edgeId];
898
+ }
899
+ /**
900
+ * Set custom data for a specific edge.
901
+ */
902
+ setCustomEdgeData(edgeId, data, options) {
903
+ if (!this.customData.edges) {
904
+ this.customData.edges = {};
905
+ }
906
+ if (data === undefined) {
907
+ delete this.customData.edges[edgeId];
908
+ if (Object.keys(this.customData.edges).length === 0) {
909
+ delete this.customData.edges;
910
+ }
911
+ }
912
+ else {
913
+ this.customData.edges[edgeId] = data;
914
+ }
915
+ this.emit("graphUiChanged", {
916
+ change: { type: "customEdgeData", edgeId, data },
917
+ ...options,
918
+ });
919
+ }
920
+ /**
921
+ * Get custom metadata.
922
+ */
923
+ getCustomMetaData() {
924
+ return this.customData.meta;
925
+ }
926
+ /**
927
+ * Set custom metadata.
928
+ */
929
+ setCustomMetaData(meta, options) {
930
+ if (meta === undefined) {
931
+ delete this.customData.meta;
932
+ }
933
+ else {
934
+ this.customData.meta = meta;
935
+ }
936
+ this.emit("graphUiChanged", {
937
+ change: { type: "customMetaData", meta },
938
+ ...options,
939
+ });
940
+ }
941
+ /**
942
+ * Get all custom data.
943
+ */
944
+ getCustomData() {
945
+ return { ...this.customData };
946
+ }
947
+ /**
948
+ * Set all custom data.
949
+ */
950
+ setCustomData(custom, options) {
951
+ if (custom === undefined) {
952
+ this.customData = {};
953
+ }
954
+ else {
955
+ this.customData = lod.pick(custom, ["nodes", "edges", "meta"]);
956
+ }
957
+ this.emit("graphUiChanged", {
958
+ change: {
959
+ type: "customData",
960
+ nodes: custom?.nodes,
961
+ edges: custom?.edges,
962
+ meta: custom?.meta,
963
+ },
964
+ ...options,
965
+ });
966
+ }
854
967
  }
855
968
 
856
969
  class CLIWorkbench {
@@ -911,36 +1024,14 @@ class AbstractGraphRunner {
911
1024
  this.stagedInputs = {};
912
1025
  this.runnerId = "";
913
1026
  }
914
- launch(def, opts) {
915
- // Auto-stop if engine is already running
916
- if (this.engine) {
917
- this.stop();
918
- }
919
- }
920
1027
  async whenIdle() {
921
1028
  await this.engine?.whenIdle();
922
1029
  }
923
- stop() {
924
- if (!this.engine)
925
- return;
926
- // Dispose engine (cleans up timers, listeners, etc.)
927
- this.engine.dispose();
928
- this.engine = undefined;
929
- // Emit status but keep runtime alive
930
- if (this.runMode) {
931
- this.runMode = undefined;
932
- this.emit("status", { running: false, runMode: undefined });
933
- }
934
- }
935
1030
  setRunMode(runMode) {
936
- if (!this.engine) {
937
- throw new Error("Cannot set run mode: engine not running");
1031
+ if (this.engine) {
1032
+ this.engine.setRunMode(runMode);
1033
+ this.emit("status", { running: true, runMode });
938
1034
  }
939
- // Update engine run mode (this will update pause/resume state)
940
- this.engine.setRunMode(runMode);
941
- // Update local state and emit status event
942
- this.runMode = runMode;
943
- this.emit("status", { running: true, runMode: this.runMode });
944
1035
  }
945
1036
  getInputDefaults(def) {
946
1037
  const out = {};
@@ -970,17 +1061,10 @@ class AbstractGraphRunner {
970
1061
  this.engine = undefined;
971
1062
  this.runtime?.dispose();
972
1063
  this.runtime = undefined;
973
- if (this.runMode) {
974
- this.runMode = undefined;
975
- this.emit("status", { running: false, runMode: undefined });
976
- }
977
1064
  }
978
1065
  isRunning() {
979
1066
  return !!this.engine;
980
1067
  }
981
- getRunMode() {
982
- return this.runMode;
983
- }
984
1068
  // Optional undo/redo support
985
1069
  async undo() {
986
1070
  return false;
@@ -999,6 +1083,7 @@ let localRunnerCounter = 0;
999
1083
  class LocalGraphRunner extends AbstractGraphRunner {
1000
1084
  constructor(registry) {
1001
1085
  super(registry, { kind: "local" });
1086
+ this.extData = {};
1002
1087
  this.setEnvironment = (env, opts) => {
1003
1088
  if (!this.runtime)
1004
1089
  return;
@@ -1047,7 +1132,10 @@ class LocalGraphRunner extends AbstractGraphRunner {
1047
1132
  }
1048
1133
  }
1049
1134
  launch(def, opts) {
1050
- super.launch(def, opts);
1135
+ if (this.engine) {
1136
+ this.engine.dispose();
1137
+ this.engine = undefined;
1138
+ }
1051
1139
  this.build(def);
1052
1140
  if (!this.runtime)
1053
1141
  throw new Error("Runtime not built");
@@ -1074,8 +1162,8 @@ class LocalGraphRunner extends AbstractGraphRunner {
1074
1162
  this.engine.on("invalidate", (e) => this.emit("invalidate", e));
1075
1163
  this.engine.on("stats", (e) => this.emit("stats", e));
1076
1164
  this.engine.launch(opts?.invalidate);
1077
- this.runMode = opts?.runMode ?? "manual";
1078
- this.emit("status", { running: true, runMode: this.runMode });
1165
+ const runMode = opts?.runMode ?? "manual";
1166
+ this.emit("status", { running: true, runMode });
1079
1167
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
1080
1168
  this.engine.setInputs(nodeId, map);
1081
1169
  }
@@ -1169,6 +1257,24 @@ class LocalGraphRunner extends AbstractGraphRunner {
1169
1257
  this.engine.copyOutputs(fromNodeId, toNodeId, options);
1170
1258
  }
1171
1259
  }
1260
+ async setExtData(data) {
1261
+ if (!data || typeof data !== "object") {
1262
+ this.extData = {};
1263
+ return;
1264
+ }
1265
+ this.extData = { ...this.extData, ...data };
1266
+ }
1267
+ async updateExtData(updates) {
1268
+ if (!this.extData ||
1269
+ typeof this.extData !== "object" ||
1270
+ Array.isArray(this.extData)) {
1271
+ this.extData = {};
1272
+ }
1273
+ for (const { path, value } of updates) {
1274
+ const pathSegments = sparkGraph.parseJsonPath(path);
1275
+ sparkGraph.setValueAtPathWithCreation(this.extData, pathSegments, value);
1276
+ }
1277
+ }
1172
1278
  async snapshotFull() {
1173
1279
  const def = undefined; // UI will supply def/positions on download for local
1174
1280
  const inputs = this.getInputs(this.runtime
@@ -1190,7 +1296,8 @@ class LocalGraphRunner extends AbstractGraphRunner {
1190
1296
  }
1191
1297
  : { nodes: [], edges: [] });
1192
1298
  const environment = this.getEnvironment() || {};
1193
- return { def, environment, inputs, outputs };
1299
+ const extData = this.extData;
1300
+ return { def, environment, inputs, outputs, extData };
1194
1301
  }
1195
1302
  async applySnapshotFull(payload, options) {
1196
1303
  if (payload.def && !options?.skipBuild) {
@@ -1198,6 +1305,9 @@ class LocalGraphRunner extends AbstractGraphRunner {
1198
1305
  }
1199
1306
  this.setEnvironment?.(payload.environment || {}, { merge: false });
1200
1307
  this.hydrate(payload, { dry: options?.dry });
1308
+ if (payload.extData) {
1309
+ await this.setExtData(payload.extData);
1310
+ }
1201
1311
  }
1202
1312
  hydrate(snapshot, opts) {
1203
1313
  // Hydrate via runtime for exact restore (this emits events on runtime emitter)
@@ -1567,7 +1677,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1567
1677
  }
1568
1678
  }
1569
1679
  launch(def, opts) {
1570
- super.launch(def, opts);
1680
+ if (this.engine) {
1681
+ this.engine.dispose();
1682
+ this.engine = undefined;
1683
+ }
1571
1684
  // Remote: build remotely then launch
1572
1685
  this.ensureClient().then(async (client) => {
1573
1686
  await client.api.build(def);
@@ -1623,8 +1736,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1623
1736
  this.listenersBound = true;
1624
1737
  }
1625
1738
  this.engine = eng;
1626
- this.runMode = opts?.runMode ?? "manual";
1627
- this.emit("status", { running: true, runMode: this.runMode });
1739
+ const runMode = opts?.runMode ?? "manual";
1740
+ this.emit("status", { running: true, runMode });
1628
1741
  // Re-apply staged inputs using client.setInputs for consistency
1629
1742
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
1630
1743
  await eng.setInputs(nodeId, map, undefined).catch(() => {
@@ -1640,7 +1753,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1640
1753
  * the runtime state that was just restored.
1641
1754
  */
1642
1755
  launchExisting(def, opts) {
1643
- super.launch(def, opts);
1756
+ if (this.engine) {
1757
+ this.engine.dispose();
1758
+ this.engine = undefined;
1759
+ }
1644
1760
  // Remote: attach to existing runtime and launch (do NOT rebuild)
1645
1761
  this.ensureClient().then(async (client) => {
1646
1762
  // NOTE: We do NOT call client.build(def) here because the backend runtime
@@ -1651,14 +1767,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1651
1767
  });
1652
1768
  }
1653
1769
  setRunMode(runMode) {
1654
- if (!this.engine) {
1655
- throw new Error("Cannot set run mode: engine not running");
1770
+ if (this.engine) {
1771
+ this.engine.setRunMode(runMode);
1772
+ this.emit("status", { running: true, runMode });
1656
1773
  }
1657
- // Update engine run mode (sends SetRunMode command to backend)
1658
- this.engine.setRunMode(runMode);
1659
- // Update local state and emit status event
1660
- this.runMode = runMode;
1661
- this.emit("status", { running: true, runMode: this.runMode });
1662
1774
  }
1663
1775
  async computeNode(nodeId, options) {
1664
1776
  const client = await this.ensureClient();
@@ -1721,6 +1833,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1721
1833
  const client = await this.ensureClient();
1722
1834
  await client.api.setExtData(data);
1723
1835
  }
1836
+ async updateExtData(updates) {
1837
+ const client = await this.ensureClient();
1838
+ await client.api.updateExtData(updates);
1839
+ }
1724
1840
  async commit(reason) {
1725
1841
  const client = await this.ensureClient();
1726
1842
  try {
@@ -2592,35 +2708,40 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2592
2708
  }));
2593
2709
  const handleLayout = geom.handleLayout;
2594
2710
  const handles = geom.handles;
2711
+ const baseData = {
2712
+ typeId: n.typeId,
2713
+ params: n.params,
2714
+ inputHandles,
2715
+ outputHandles,
2716
+ inputConnected: Object.fromEntries(inputHandles.map((h) => [
2717
+ h.id,
2718
+ !!connectedInputs[n.nodeId]?.has(h.id),
2719
+ ])),
2720
+ handleLayout,
2721
+ showValues: opts.showValues,
2722
+ renderWidth,
2723
+ renderHeight,
2724
+ initialWidth: initialGeom.width,
2725
+ initialHeight: initialGeom.height,
2726
+ inputValues: opts.inputs?.[n.nodeId],
2727
+ inputDefaults: opts.inputDefaults?.[n.nodeId],
2728
+ outputValues: opts.outputs?.[n.nodeId],
2729
+ status: opts.nodeStatus?.[n.nodeId],
2730
+ validation: {
2731
+ inputs: opts.nodeValidation?.inputs?.[n.nodeId] ?? [],
2732
+ outputs: opts.nodeValidation?.outputs?.[n.nodeId] ?? [],
2733
+ issues: opts.nodeValidation?.issues?.[n.nodeId] ?? [],
2734
+ },
2735
+ toString: opts.toString,
2736
+ toElement: opts.toElement,
2737
+ };
2738
+ const customNodeData = opts.customData?.nodes;
2739
+ const mergedData = customNodeData?.[n.nodeId]
2740
+ ? { ...baseData, custom: customNodeData[n.nodeId] }
2741
+ : baseData;
2595
2742
  return {
2596
2743
  id: n.nodeId,
2597
- data: {
2598
- typeId: n.typeId,
2599
- params: n.params,
2600
- inputHandles,
2601
- outputHandles,
2602
- inputConnected: Object.fromEntries(inputHandles.map((h) => [
2603
- h.id,
2604
- !!connectedInputs[n.nodeId]?.has(h.id),
2605
- ])),
2606
- handleLayout,
2607
- showValues: opts.showValues,
2608
- renderWidth,
2609
- renderHeight,
2610
- initialWidth: initialGeom.width,
2611
- initialHeight: initialGeom.height,
2612
- inputValues: opts.inputs?.[n.nodeId],
2613
- inputDefaults: opts.inputDefaults?.[n.nodeId],
2614
- outputValues: opts.outputs?.[n.nodeId],
2615
- status: opts.nodeStatus?.[n.nodeId],
2616
- validation: {
2617
- inputs: opts.nodeValidation?.inputs?.[n.nodeId] ?? [],
2618
- outputs: opts.nodeValidation?.outputs?.[n.nodeId] ?? [],
2619
- issues: opts.nodeValidation?.issues?.[n.nodeId] ?? [],
2620
- },
2621
- toString: opts.toString,
2622
- toElement: opts.toElement,
2623
- },
2744
+ data: mergedData,
2624
2745
  position: positions[n.nodeId] ?? { x: 0, y: 0 },
2625
2746
  type: opts.resolveNodeType?.(n.typeId) ?? "spark-default",
2626
2747
  selected: opts.selectedNodeIds
@@ -2663,6 +2784,25 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2663
2784
  const targetHandleTypeId = targetHandles?.inputs[e.target.handle]
2664
2785
  ? sparkGraph.getInputTypeId(targetHandles.inputs, e.target.handle) ?? "unknown"
2665
2786
  : "unknown";
2787
+ const baseEdgeData = {
2788
+ sourceNodeId: e.source.nodeId,
2789
+ sourceNodeTypeId: sourceNode?.typeId || "unknown",
2790
+ sourceHandle: e.source.handle,
2791
+ sourceHandleTypeId,
2792
+ targetNodeId: e.target.nodeId,
2793
+ targetNodeTypeId: targetNode?.typeId || "unknown",
2794
+ targetHandle: e.target.handle,
2795
+ targetHandleTypeId,
2796
+ edgeTypeId,
2797
+ isRunning,
2798
+ hasError,
2799
+ isInvalid: isInvalidEdge,
2800
+ isMissing,
2801
+ };
2802
+ const customEdgeData = opts.customData?.edges;
2803
+ const mergedEdgeData = customEdgeData?.[e.id]
2804
+ ? { ...baseEdgeData, custom: customEdgeData[e.id] }
2805
+ : baseEdgeData;
2666
2806
  return {
2667
2807
  id: e.id,
2668
2808
  source: e.source.nodeId,
@@ -2676,21 +2816,7 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2676
2816
  style,
2677
2817
  label: edgeTypeId,
2678
2818
  type: "default",
2679
- data: {
2680
- sourceNodeId: e.source.nodeId,
2681
- sourceNodeTypeId: sourceNode?.typeId || "unknown",
2682
- sourceHandle: e.source.handle,
2683
- sourceHandleTypeId,
2684
- targetNodeId: e.target.nodeId,
2685
- targetNodeTypeId: targetNode?.typeId || "unknown",
2686
- targetHandle: e.target.handle,
2687
- targetHandleTypeId,
2688
- edgeTypeId,
2689
- isRunning,
2690
- hasError,
2691
- isInvalid: isInvalidEdge,
2692
- isMissing,
2693
- },
2819
+ data: mergedEdgeData,
2694
2820
  };
2695
2821
  });
2696
2822
  return { nodes, edges };
@@ -2846,6 +2972,9 @@ async function upload(parsed, wb, runner) {
2846
2972
  if (extData.runtime && typeof extData.runtime === "object") {
2847
2973
  wb.setRuntimeState(extData.runtime);
2848
2974
  }
2975
+ if (extData.custom && typeof extData.custom === "object") {
2976
+ wb.setCustomData(extData.custom);
2977
+ }
2849
2978
  if (runner.isRunning()) {
2850
2979
  await runner.applySnapshotFull({
2851
2980
  def: wb.def,
@@ -2862,6 +2991,9 @@ async function upload(parsed, wb, runner) {
2862
2991
  runner.setInputs(nodeId, map, { dry: true });
2863
2992
  }
2864
2993
  }
2994
+ if (extData) {
2995
+ await runner.setExtData(extData);
2996
+ }
2865
2997
  }
2866
2998
  }
2867
2999
 
@@ -3658,15 +3790,7 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
3658
3790
  const graphUiTick = useWorkbenchGraphUiTick(wb);
3659
3791
  const versionTick = useWorkbenchVersionTick(runner);
3660
3792
  const valuesTick = versionTick + graphTick + graphUiTick;
3661
- // Keep local runMode state loosely in sync with runner status.
3662
- // - Seed from runner.getRunMode() on mount if available.
3663
- // - On status events, update only when a non-undefined runMode is reported,
3664
- // so the UI preserves the last selected mode after stop().
3665
3793
  React.useEffect(() => {
3666
- const initialMode = runner.getRunMode();
3667
- if (initialMode) {
3668
- setRunModeState(initialMode);
3669
- }
3670
3794
  const offRunnerStatus = runner.on("status", (status) => {
3671
3795
  if (status.runMode) {
3672
3796
  setRunModeState(status.runMode);
@@ -3826,12 +3950,13 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
3826
3950
  workbench.setRuntimeState(metadata);
3827
3951
  const fullUiState = workbench.getUIState();
3828
3952
  const uiWithoutViewport = excludeViewportFromUIState(fullUiState);
3829
- await graphRunner.setExtData?.({
3830
- ...(Object.keys(uiWithoutViewport || {}).length > 0
3831
- ? { ui: uiWithoutViewport }
3832
- : {}),
3833
- runtime: metadata,
3834
- });
3953
+ // Use updateExtData for efficient batched partial updates
3954
+ const updates = [];
3955
+ if (Object.keys(uiWithoutViewport || {}).length > 0) {
3956
+ updates.push({ path: "ui", value: uiWithoutViewport });
3957
+ }
3958
+ updates.push({ path: "runtime", value: metadata });
3959
+ await graphRunner.updateExtData(updates);
3835
3960
  }
3836
3961
  catch (err) {
3837
3962
  console.warn("[WorkbenchContext] Failed to save runtime metadata:", err);
@@ -4264,6 +4389,18 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4264
4389
  else if (changeType === "selection") {
4265
4390
  reason = "selection";
4266
4391
  }
4392
+ else if (changeType === "customNodeData") {
4393
+ reason = "custom-node-data";
4394
+ }
4395
+ else if (changeType === "customEdgeData") {
4396
+ reason = "custom-edge-data";
4397
+ }
4398
+ else if (changeType === "customMetaData") {
4399
+ reason = "custom-meta";
4400
+ }
4401
+ else if (changeType === "customData") {
4402
+ reason = "custom";
4403
+ }
4267
4404
  }
4268
4405
  await saveUiRuntimeMetadata(wb, runner);
4269
4406
  const history = await runner
@@ -4369,27 +4506,11 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4369
4506
  };
4370
4507
  }, [runner, wb]);
4371
4508
  const isRunning = React.useCallback(() => runner.isRunning(), [runner]);
4372
- const getRunMode = React.useCallback(() => runner.getRunMode(), [runner]);
4373
- const stop = React.useCallback(() => runner.stop(), [runner]);
4374
- // Run mode actions
4375
4509
  const setRunMode = React.useCallback((mode) => {
4376
4510
  if (mode === runMode)
4377
4511
  return;
4378
- const wasRunning = runner.isRunning();
4379
- if (wasRunning) {
4380
- // Use setRunMode to change run mode without rebuilding
4381
- try {
4382
- runner.setRunMode(mode);
4383
- setRunModeState(mode);
4384
- }
4385
- catch (err) {
4386
- console.error("Failed to set run mode:", err);
4387
- }
4388
- }
4389
- else {
4390
- // Just update state if not running (will be applied on next launch)
4391
- setRunModeState(mode);
4392
- }
4512
+ runner.setRunMode(mode);
4513
+ setRunModeState(mode);
4393
4514
  }, [runMode, runner]);
4394
4515
  const runNodeAction = React.useCallback(async (nodeId) => {
4395
4516
  await runner.computeNode(nodeId);
@@ -4491,8 +4612,6 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4491
4612
  removeRegistryError,
4492
4613
  removeInputValidationError,
4493
4614
  isRunning,
4494
- getRunMode,
4495
- stop,
4496
4615
  runMode,
4497
4616
  setRunMode,
4498
4617
  runNode: runNodeAction,
@@ -4533,8 +4652,6 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4533
4652
  events,
4534
4653
  clearEvents,
4535
4654
  isRunning,
4536
- getRunMode,
4537
- stop,
4538
4655
  runMode,
4539
4656
  setRunMode,
4540
4657
  runNodeAction,
@@ -5401,7 +5518,7 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
5401
5518
  !handlers.onRedo && jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxRuntime.jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsxRuntime.jsx("div", { className: "px-2 pb-1", children: jsxRuntime.jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Filter nodes...", className: "w-full border border-gray-300 rounded px-2 py-1 text-sm outline-none focus:border-gray-400 select-text", onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation() }) }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsxRuntime.jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
5402
5519
  }
5403
5520
 
5404
- function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, runMode, enableKeyboardShortcuts = true, keyboardShortcuts = {
5521
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, runMode, wb, enableKeyboardShortcuts = true, keyboardShortcuts = {
5405
5522
  copy: "⌘/Ctrl + C",
5406
5523
  duplicate: "⌘/Ctrl + E",
5407
5524
  duplicateWithEdges: "⌘/Ctrl + Shift + E",
@@ -5435,6 +5552,10 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, r
5435
5552
  }, [open]);
5436
5553
  if (!open || !clientPos || !nodeId)
5437
5554
  return null;
5555
+ // Determine if this is a start node (no inbound edges)
5556
+ const isStartNode = wb
5557
+ ? !wb.def.edges.some((e) => e.target.nodeId === nodeId)
5558
+ : false;
5438
5559
  // clamp
5439
5560
  const MENU_MIN_WIDTH = 180;
5440
5561
  const PADDING = 16;
@@ -5444,7 +5565,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, r
5444
5565
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
5445
5566
  e.preventDefault();
5446
5567
  e.stopPropagation();
5447
- }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx(ContextMenuButton, { label: "Duplicate", onClick: handlers.onDuplicate, shortcut: keyboardShortcuts.duplicate, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx(ContextMenuButton, { label: "Duplicate with edges", onClick: handlers.onDuplicateWithEdges, shortcut: keyboardShortcuts.duplicateWithEdges, enableKeyboardShortcuts: enableKeyboardShortcuts }), runMode === "manual" && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [handlers.onRunNode && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunNode, children: "Run node" })), handlers.onRunFromHere && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunFromHere, children: "Run from here" }))] })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
5568
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx(ContextMenuButton, { label: "Duplicate", onClick: handlers.onDuplicate, shortcut: keyboardShortcuts.duplicate, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx(ContextMenuButton, { label: "Duplicate with edges", onClick: handlers.onDuplicateWithEdges, shortcut: keyboardShortcuts.duplicateWithEdges, enableKeyboardShortcuts: enableKeyboardShortcuts }), runMode === "manual" && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [handlers.onRunNode && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunNode, children: "Run node" })), handlers.onRunFromHere && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunFromHere, children: isStartNode ? "Run workflow" : "Run from here" }))] })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
5448
5569
  }
5449
5570
 
5450
5571
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -5530,7 +5651,56 @@ function useKeyboardShortcutToast() {
5530
5651
  return { toast, showToast, hideToast };
5531
5652
  }
5532
5653
 
5533
- const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
5654
+ const SelectionBoundOverlay = ({ selection, rfInstance }) => {
5655
+ const selectionBounds = React.useMemo(() => {
5656
+ if (typeof document === "undefined" ||
5657
+ !rfInstance ||
5658
+ selection.nodes.length < 2) {
5659
+ return null;
5660
+ }
5661
+ let bounds = null;
5662
+ for (const nodeId of selection.nodes) {
5663
+ const el = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`);
5664
+ if (!el)
5665
+ continue;
5666
+ const rect = el.getBoundingClientRect();
5667
+ if (!bounds) {
5668
+ bounds = {
5669
+ left: rect.left,
5670
+ top: rect.top,
5671
+ right: rect.right,
5672
+ bottom: rect.bottom,
5673
+ };
5674
+ }
5675
+ else {
5676
+ bounds.left = Math.min(bounds.left, rect.left);
5677
+ bounds.top = Math.min(bounds.top, rect.top);
5678
+ bounds.right = Math.max(bounds.right, rect.right);
5679
+ bounds.bottom = Math.max(bounds.bottom, rect.bottom);
5680
+ }
5681
+ }
5682
+ return bounds;
5683
+ }, [selection.nodes, rfInstance]);
5684
+ if (!selectionBounds || selection.nodes.length < 2) {
5685
+ return null;
5686
+ }
5687
+ const { left, top, right, bottom } = selectionBounds;
5688
+ const width = right - left;
5689
+ const height = bottom - top;
5690
+ return (jsxRuntime.jsx("div", { style: {
5691
+ position: "fixed",
5692
+ left: `${left}px`,
5693
+ top: `${top}px`,
5694
+ width: `${width}px`,
5695
+ height: `${height}px`,
5696
+ border: "1px dashed #0ea5e9",
5697
+ pointerEvents: "none",
5698
+ zIndex: 4,
5699
+ boxSizing: "border-box",
5700
+ } }));
5701
+ };
5702
+
5703
+ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize, reactFlowProps }, ref) => {
5534
5704
  const { wb, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, uiVersion, registryVersion, runner, overrides, runNode, runFromHere, runMode, } = useWorkbenchContext();
5535
5705
  const nodeValidation = validationByNode;
5536
5706
  const edgeValidation = validationByEdge.errors;
@@ -5568,6 +5738,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5568
5738
  status: n.data.status,
5569
5739
  validation: n.data.validation,
5570
5740
  inputConnected: n.data.inputConnected,
5741
+ custom: n.data.custom,
5571
5742
  },
5572
5743
  });
5573
5744
  return lod.isEqual(pick(a), pick(b));
@@ -5583,6 +5754,14 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5583
5754
  style: e.style,
5584
5755
  label: e.label,
5585
5756
  type: e.type,
5757
+ data: e.data && {
5758
+ edgeTypeId: e.data.edgeTypeId,
5759
+ isRunning: e.data.isRunning,
5760
+ hasError: e.data.hasError,
5761
+ isInvalid: e.data.isInvalid,
5762
+ isMissing: e.data.isMissing,
5763
+ custom: e.data.custom,
5764
+ },
5586
5765
  });
5587
5766
  return lod.isEqual(pick(a), pick(b));
5588
5767
  };
@@ -5636,27 +5815,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5636
5815
  }, [uiVersion, ui]);
5637
5816
  const { nodes, edges } = React.useMemo(() => {
5638
5817
  const sel = wb.getSelection();
5639
- // Merge defaults with inputs for node display (defaults shown in lighter gray)
5640
- const inputsWithDefaults = {};
5641
- for (const n of wb.def.nodes) {
5642
- const nodeInputs = inputsMap[n.nodeId] ?? {};
5643
- const nodeDefaults = inputDefaultsMap[n.nodeId] ?? {};
5644
- const inbound = new Set(wb.def.edges
5645
- .filter((e) => e.target.nodeId === n.nodeId)
5646
- .map((e) => e.target.handle));
5647
- const merged = { ...nodeInputs };
5648
- for (const [h, v] of Object.entries(nodeDefaults)) {
5649
- if (!inbound.has(h) && merged[h] === undefined) {
5650
- merged[h] = v;
5651
- }
5652
- }
5653
- if (Object.keys(merged).length > 0) {
5654
- inputsWithDefaults[n.nodeId] = merged;
5655
- }
5656
- }
5657
5818
  const out = toReactFlow(wb.def, wb.getPositions(), wb.getSizes(), wb.registry, {
5658
5819
  showValues,
5659
- inputs: inputsWithDefaults,
5820
+ inputs: inputsMap,
5660
5821
  inputDefaults: inputDefaultsMap,
5661
5822
  outputs: outputsMap,
5662
5823
  resolveNodeType,
@@ -5670,6 +5831,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5670
5831
  selectedEdgeIds: new Set(sel.edges),
5671
5832
  getDefaultNodeSize,
5672
5833
  ui,
5834
+ customData: wb.getCustomData(),
5673
5835
  });
5674
5836
  // Retain references for unchanged items
5675
5837
  const stableNodes = retainStabilityById(prevNodesRef.current, out.nodes, isSameNode);
@@ -5774,13 +5936,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5774
5936
  resolveNodeType,
5775
5937
  ]);
5776
5938
  const throttled = useThrottledValue({ nodes, edges }, 100);
5777
- const [menuOpen, setMenuOpen] = React.useState(false);
5778
- const [menuPos, setMenuPos] = React.useState(null);
5779
- const [nodeMenuOpen, setNodeMenuOpen] = React.useState(false);
5780
- const [nodeMenuPos, setNodeMenuPos] = React.useState(null);
5781
- const [nodeAtMenu, setNodeAtMenu] = React.useState(null);
5782
- const [selectionMenuPos, setSelectionMenuPos] = React.useState(null);
5783
- const [selectionMenuOpen, setSelectionMenuOpen] = React.useState(false);
5939
+ const [menuState, setMenuState] = React.useState(null);
5784
5940
  // Compute the rectangular screen-space bounds of the current selection
5785
5941
  const getSelectionScreenBounds = () => {
5786
5942
  if (typeof document === "undefined")
@@ -5821,31 +5977,24 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5821
5977
  if (target) {
5822
5978
  // Resolve node id from data-id attribute React Flow sets
5823
5979
  const nodeId = target.getAttribute("data-id");
5824
- const isSelected = nodeId && selection.nodes.includes(nodeId);
5825
- if (isSelected && isSingleNodeSelected) {
5826
- // Right-clicked on the single selected node - show node menu
5827
- setNodeAtMenu(nodeId);
5828
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5829
- setNodeMenuOpen(true);
5830
- setMenuOpen(false);
5831
- setSelectionMenuOpen(false);
5980
+ if (!nodeId)
5832
5981
  return;
5833
- }
5834
- else if (isSelected) {
5982
+ const isSelected = selection.nodes.includes(nodeId);
5983
+ if (isSelected && !isSingleNodeSelected) {
5835
5984
  // Right-clicked on a node that's part of multi-selection - show selection menu
5836
- setSelectionMenuPos({ x: e.clientX, y: e.clientY });
5837
- setSelectionMenuOpen(true);
5838
- setMenuOpen(false);
5839
- setNodeMenuOpen(false);
5985
+ setMenuState({
5986
+ type: "selection",
5987
+ menuPos: { x: e.clientX, y: e.clientY },
5988
+ });
5840
5989
  return;
5841
5990
  }
5842
5991
  else {
5843
5992
  // Right-clicked on a non-selected node - show node menu
5844
- setNodeAtMenu(nodeId);
5845
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5846
- setNodeMenuOpen(true);
5847
- setMenuOpen(false);
5848
- setSelectionMenuOpen(false);
5993
+ setMenuState({
5994
+ type: "node",
5995
+ menuPos: { x: e.clientX, y: e.clientY },
5996
+ nodeId,
5997
+ });
5849
5998
  return;
5850
5999
  }
5851
6000
  }
@@ -5857,32 +6006,22 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5857
6006
  if (isSelected && isSingleNodeSelected) {
5858
6007
  // Right-clicked on an edge, but only one node is selected - show node menu
5859
6008
  const nodeId = selection.nodes[0];
5860
- setNodeAtMenu(nodeId);
5861
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5862
- setNodeMenuOpen(true);
5863
- setMenuOpen(false);
5864
- setSelectionMenuOpen(false);
6009
+ setMenuState({
6010
+ type: "node",
6011
+ menuPos: { x: e.clientX, y: e.clientY },
6012
+ nodeId,
6013
+ });
5865
6014
  return;
5866
6015
  }
5867
6016
  else if (isSelected) {
5868
6017
  // Right-clicked on a selected edge with multiple nodes - show selection menu
5869
- setSelectionMenuPos({ x: e.clientX, y: e.clientY });
5870
- setSelectionMenuOpen(true);
5871
- setMenuOpen(false);
5872
- setNodeMenuOpen(false);
6018
+ setMenuState({
6019
+ type: "selection",
6020
+ menuPos: { x: e.clientX, y: e.clientY },
6021
+ });
5873
6022
  return;
5874
6023
  }
5875
6024
  }
5876
- // If only one node is selected (even with edges), show node menu for empty space clicks
5877
- if (isSingleNodeSelected) {
5878
- const nodeId = selection.nodes[0];
5879
- setNodeAtMenu(nodeId);
5880
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5881
- setNodeMenuOpen(true);
5882
- setMenuOpen(false);
5883
- setSelectionMenuOpen(false);
5884
- return;
5885
- }
5886
6025
  // Check if the cursor is inside the rectangular bounds of the current selection
5887
6026
  // (for multi-selection when right-clicking on empty space within selection bounds)
5888
6027
  const selectionBounds = getSelectionScreenBounds();
@@ -5892,28 +6031,38 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5892
6031
  e.clientX <= right &&
5893
6032
  e.clientY >= top &&
5894
6033
  e.clientY <= bottom) {
5895
- setSelectionMenuPos({ x: e.clientX, y: e.clientY });
5896
- setSelectionMenuOpen(true);
5897
- setMenuOpen(false);
5898
- setNodeMenuOpen(false);
6034
+ // If only one node is selected (even with edges), show node menu for empty space clicks
6035
+ if (isSingleNodeSelected) {
6036
+ const nodeId = selection.nodes[0];
6037
+ setMenuState({
6038
+ type: "node",
6039
+ menuPos: { x: e.clientX, y: e.clientY },
6040
+ nodeId,
6041
+ });
6042
+ return;
6043
+ }
6044
+ setMenuState({
6045
+ type: "selection",
6046
+ menuPos: { x: e.clientX, y: e.clientY },
6047
+ });
5899
6048
  return;
5900
6049
  }
5901
6050
  }
5902
6051
  // Right-clicked on empty space with no selection - show default menu
5903
- setMenuPos({ x: e.clientX, y: e.clientY });
5904
- setMenuOpen(true);
5905
- setNodeMenuOpen(false);
5906
- setSelectionMenuOpen(false);
6052
+ setMenuState({
6053
+ type: "default",
6054
+ menuPos: { x: e.clientX, y: e.clientY },
6055
+ });
5907
6056
  };
5908
6057
  const addNodeAt = React.useCallback(async (typeId, opts) => wb.addNode({ typeId }, { inputs: opts.inputs, position: opts.position, commit: true }), [wb]);
5909
6058
  const onCloseMenu = React.useCallback(() => {
5910
- setMenuOpen(false);
6059
+ setMenuState(null);
5911
6060
  }, []);
5912
6061
  const onCloseNodeMenu = React.useCallback(() => {
5913
- setNodeMenuOpen(false);
6062
+ setMenuState(null);
5914
6063
  }, []);
5915
6064
  const onCloseSelectionMenu = React.useCallback(() => {
5916
- setSelectionMenuOpen(false);
6065
+ setMenuState(null);
5917
6066
  }, []);
5918
6067
  React.useEffect(() => {
5919
6068
  const off = wb.on("historyChanged", (event) => {
@@ -5960,8 +6109,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5960
6109
  return baseHandlers;
5961
6110
  }, [wb, runner, overrides, onCloseSelectionMenu]);
5962
6111
  const nodeContextMenuHandlers = React.useMemo(() => {
5963
- if (!nodeAtMenu)
6112
+ if (menuState?.type !== "node")
5964
6113
  return null;
6114
+ const nodeAtMenu = menuState.nodeId;
5965
6115
  // Get storage from override or use workbench's internal storage
5966
6116
  const storage = overrides?.getCopiedDataStorage
5967
6117
  ? overrides.getCopiedDataStorage()
@@ -5977,7 +6127,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5977
6127
  }
5978
6128
  return baseHandlers;
5979
6129
  }, [
5980
- nodeAtMenu,
6130
+ menuState,
5981
6131
  wb,
5982
6132
  runner,
5983
6133
  wb.registry,
@@ -5990,10 +6140,10 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5990
6140
  overrides?.getCopiedDataStorage,
5991
6141
  ]);
5992
6142
  const bakeableOutputs = React.useMemo(() => {
5993
- if (!nodeAtMenu)
6143
+ if (menuState?.type !== "node")
5994
6144
  return [];
5995
- return getBakeableOutputs(nodeAtMenu, wb, wb.registry, outputTypesMap);
5996
- }, [nodeAtMenu, wb, wb.registry, registryVersion, outputTypesMap]);
6145
+ return getBakeableOutputs(menuState.nodeId, wb, wb.registry, outputTypesMap);
6146
+ }, [menuState, wb, wb.registry, registryVersion, outputTypesMap]);
5997
6147
  // Keyboard shortcuts configuration
5998
6148
  const enableKeyboardShortcuts = overrides?.enableKeyboardShortcuts !== false; // Default to true
5999
6149
  const keyboardShortcuts = overrides?.keyboardShortcuts || {
@@ -6063,7 +6213,8 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
6063
6213
  const modKeyLabel = isMac ? "⌘" : "Ctrl";
6064
6214
  showToast(`Copy (${modKeyLabel} + C)`);
6065
6215
  // If single node selected, use node context menu handler; otherwise use selection handler
6066
- if (selection.nodes.length === 1 && nodeContextMenuHandlers?.onCopy) {
6216
+ if (selection.nodes.length === 1 &&
6217
+ nodeContextMenuHandlers?.onCopy) {
6067
6218
  nodeContextMenuHandlers.onCopy();
6068
6219
  }
6069
6220
  else if (selectionContextMenuHandlers.onCopy) {
@@ -6183,21 +6334,25 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
6183
6334
  });
6184
6335
  return () => off();
6185
6336
  }, [wb]);
6186
- return (jsxRuntime.jsxs("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: [jsxRuntime.jsx(react.ReactFlowProvider, { children: jsxRuntime.jsxs(react.ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, connectionLineComponent: connectionLineRenderer, selectionOnDrag: true, onInit: (inst) => {
6187
- rfInstanceRef.current = inst;
6188
- const savedViewport = wb.getViewport();
6189
- if (savedViewport) {
6190
- inst.setViewport(lod.clone(savedViewport));
6191
- }
6192
- }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onMoveEnd: onMoveEnd, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", children: [BackgroundRenderer ? (jsxRuntime.jsx(BackgroundRenderer, {})) : (jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsxRuntime.jsx(MinimapRenderer, {}) : jsxRuntime.jsx(react.MiniMap, {}), ControlsRenderer ? jsxRuntime.jsx(ControlsRenderer, {}) : jsxRuntime.jsx(react.Controls, {}), DefaultContextMenuRenderer ? (jsxRuntime.jsx(DefaultContextMenuRenderer, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, ...(enableKeyboardShortcuts !== false
6193
- ? { enableKeyboardShortcuts, keyboardShortcuts }
6194
- : {}) })) : (jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
6195
- nodeContextMenuHandlers &&
6196
- (NodeContextMenuRenderer ? (jsxRuntime.jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode, wb: wb, ...(enableKeyboardShortcuts !== false
6197
- ? { enableKeyboardShortcuts, keyboardShortcuts }
6198
- : {}) })) : (jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode }))), selectionMenuOpen &&
6199
- selectionMenuPos &&
6200
- (SelectionContextMenuRenderer ? (jsxRuntime.jsx(SelectionContextMenuRenderer, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })) : (jsxRuntime.jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })))] }) }), toast && (jsxRuntime.jsx(KeyboardShortcutToast, { message: toast.message, onClose: hideToast }, toast.id))] }));
6337
+ const { onInit: userOnInit, ...restReactFlowProps } = reactFlowProps || {};
6338
+ return (jsxRuntime.jsxs("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: [jsxRuntime.jsxs(react.ReactFlowProvider, { children: [jsxRuntime.jsxs(react.ReactFlow, { ...restReactFlowProps, nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, connectionLineComponent: connectionLineRenderer, selectionOnDrag: true, onInit: (inst) => {
6339
+ rfInstanceRef.current = inst;
6340
+ const savedViewport = wb.getViewport();
6341
+ if (savedViewport) {
6342
+ inst.setViewport(lod.clone(savedViewport));
6343
+ }
6344
+ if (userOnInit) {
6345
+ userOnInit(inst);
6346
+ }
6347
+ }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onMoveEnd: onMoveEnd, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", children: [BackgroundRenderer ? (jsxRuntime.jsx(BackgroundRenderer, {})) : (jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsxRuntime.jsx(MinimapRenderer, {}) : jsxRuntime.jsx(react.MiniMap, {}), ControlsRenderer ? jsxRuntime.jsx(ControlsRenderer, {}) : jsxRuntime.jsx(react.Controls, {}), menuState?.type === "default" &&
6348
+ (DefaultContextMenuRenderer ? (jsxRuntime.jsx(DefaultContextMenuRenderer, { open: true, clientPos: menuState.menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, ...(enableKeyboardShortcuts !== false
6349
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
6350
+ : {}) })) : (jsxRuntime.jsx(DefaultContextMenu, { open: true, clientPos: menuState.menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))), menuState?.type === "node" &&
6351
+ nodeContextMenuHandlers &&
6352
+ (NodeContextMenuRenderer ? (jsxRuntime.jsx(NodeContextMenuRenderer, { open: true, clientPos: menuState.menuPos, nodeId: menuState.nodeId, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode, wb: wb, ...(enableKeyboardShortcuts !== false
6353
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
6354
+ : {}) })) : (jsxRuntime.jsx(NodeContextMenu, { open: true, clientPos: menuState.menuPos, nodeId: menuState.nodeId, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode }))), menuState?.type === "selection" &&
6355
+ (SelectionContextMenuRenderer ? (jsxRuntime.jsx(SelectionContextMenuRenderer, { open: true, clientPos: menuState.menuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })) : (jsxRuntime.jsx(SelectionContextMenu, { open: true, clientPos: menuState.menuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })))] }), jsxRuntime.jsx(SelectionBoundOverlay, { selection: wb.getSelection(), rfInstance: rfInstanceRef.current })] }), toast && (jsxRuntime.jsx(KeyboardShortcutToast, { message: toast.message, onClose: hideToast }, toast.id))] }));
6201
6356
  });
6202
6357
 
6203
6358
  function WorkbenchStudioCanvas({ autoScroll, onAutoScrollChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
@@ -6225,9 +6380,6 @@ function WorkbenchStudioCanvas({ autoScroll, onAutoScrollChange, example, onExam
6225
6380
  if (isConnecting) {
6226
6381
  return (jsxRuntime.jsxs("button", { className: "border rounded px-2 py-1.5 text-gray-500 border-gray-400 flex items-center gap-1 disabled:opacity-50", disabled: true, title: "Connecting to backend...", children: [jsxRuntime.jsx(react$1.ClockClockwiseIcon, { size: 16, className: "animate-spin" }), jsxRuntime.jsx("span", { className: "font-medium ml-1", children: "Connecting..." })] }));
6227
6382
  }
6228
- if (isGraphRunning) {
6229
- return (jsxRuntime.jsxs("button", { className: "border rounded px-2 py-1.5 text-red-700 border-red-600 flex items-center gap-1 disabled:opacity-50 disabled:text-gray-400 disabled:border-gray-300", onClick: () => runner.stop(), disabled: !canControl, title: canControl ? "Stop engine" : "Waiting for connection", children: [jsxRuntime.jsx(react$1.StopIcon, { size: 16, weight: "fill" }), jsxRuntime.jsx("span", { className: "font-medium ml-1", children: "Stop" })] }));
6230
- }
6231
6383
  return (jsxRuntime.jsxs("button", { className: "border rounded px-2 py-1.5 text-green-700 border-green-600 flex items-center gap-1 disabled:text-gray-400 disabled:border-gray-300 disabled:opacity-50", onClick: (evt) => {
6232
6384
  if (evt.shiftKey && !confirm("Invalidate and re-run graph?"))
6233
6385
  return;
@@ -6614,11 +6766,12 @@ function WorkbenchStudioCanvas({ autoScroll, onAutoScrollChange, example, onExam
6614
6766
  if (mode !== runMode) {
6615
6767
  await setRunMode(mode);
6616
6768
  }
6617
- }, disabled: isGraphRunning, title: isGraphRunning
6618
- ? "Stop before switching run mode"
6619
- : "Select run mode", children: [jsxRuntime.jsx("option", { value: "manual", children: "Manual" }), jsxRuntime.jsx("option", { value: "auto", children: "Auto" })] }), renderStartStopButton(), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsxRuntime.jsx(react$1.TreeStructureIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsxRuntime.jsx(react$1.CornersOutIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsxRuntime.jsx(react$1.DownloadIcon, { size: 24 }) }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsxRuntime.jsx(react$1.UploadIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
6769
+ }, title: "Select run mode", children: [jsxRuntime.jsx("option", { value: "manual", children: "Manual" }), jsxRuntime.jsx("option", { value: "auto", children: "Auto" })] }), renderStartStopButton(), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsxRuntime.jsx(react$1.TreeStructureIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsxRuntime.jsx(react$1.CornersOutIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsxRuntime.jsx(react$1.DownloadIcon, { size: 24 }) }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsxRuntime.jsx(react$1.UploadIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
6620
6770
  await downloadCanvasThumbnail(canvasContainerRef.current);
6621
- }, title: "Download Flow Thumbnail (SVG)", children: jsxRuntime.jsx(react$1.ImageIcon, { size: 24 }) }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx(react$1.BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx(react$1.ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", ref: canvasContainerRef, children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
6771
+ }, title: "Download Flow Thumbnail (SVG)", children: jsxRuntime.jsx(react$1.ImageIcon, { size: 24 }) }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx(react$1.BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx(react$1.ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", ref: canvasContainerRef, children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize, reactFlowProps: {
6772
+ minZoom: 0.1,
6773
+ maxZoom: 5,
6774
+ } }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
6622
6775
  }
6623
6776
  function WorkbenchStudio({ example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
6624
6777
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());