@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/esm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { generateId, createSimpleGraphRegistry, GraphBuilder, getTypedOutputValue, isTypedOutput, getInputTypeId, LocalEngine, getTypedOutputTypeId, isInputPrivate, offsetImportedPositions, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
1
+ import { generateId, createSimpleGraphRegistry, GraphBuilder, getTypedOutputValue, isTypedOutput, getInputTypeId, LocalEngine, parseJsonPath, setValueAtPathWithCreation, getTypedOutputTypeId, isInputPrivate, offsetImportedPositions, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
2
2
  import lod from 'lodash';
3
3
  import { RemoteRuntimeClient } from '@bian-womp/spark-remote';
4
4
  import { Position, Handle, NodeResizer, getBezierPath, BaseEdge, useReactFlow, ReactFlowProvider, ReactFlow, Background, BackgroundVariant, MiniMap, Controls } from '@xyflow/react';
@@ -144,6 +144,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
144
144
  edges: [],
145
145
  };
146
146
  this.nodeNames = {};
147
+ this.customData = {};
147
148
  this.runtimeState = null;
148
149
  this.viewport = null;
149
150
  this.historyState = undefined;
@@ -172,6 +173,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
172
173
  const filteredSizes = Object.fromEntries(Object.entries(this.sizes).filter(([id]) => defNodeIds.has(id)));
173
174
  const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
174
175
  const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
176
+ // Clean up extData for removed nodes/edges
177
+ if (this.customData.nodes) {
178
+ const filteredExtNodes = Object.fromEntries(Object.entries(this.customData.nodes).filter(([id]) => defNodeIds.has(id)));
179
+ this.customData.nodes =
180
+ Object.keys(filteredExtNodes).length > 0 ? filteredExtNodes : undefined;
181
+ }
182
+ if (this.customData.edges) {
183
+ const filteredExtEdges = Object.fromEntries(Object.entries(this.customData.edges).filter(([id]) => defEdgeIds.has(id)));
184
+ this.customData.edges =
185
+ Object.keys(filteredExtEdges).length > 0 ? filteredExtEdges : undefined;
186
+ }
175
187
  this.positions = filteredPositions;
176
188
  this.sizes = filteredSizes;
177
189
  this.selection = { nodes: filteredNodes, edges: filteredEdges };
@@ -849,6 +861,107 @@ class InMemoryWorkbench extends AbstractWorkbench {
849
861
  ...options,
850
862
  });
851
863
  }
864
+ /**
865
+ * Get custom data for a specific node.
866
+ */
867
+ getCustomNodeData(nodeId) {
868
+ return this.customData.nodes?.[nodeId];
869
+ }
870
+ /**
871
+ * Set custom data for a specific node.
872
+ */
873
+ setCustomNodeData(nodeId, data, options) {
874
+ if (!this.customData.nodes) {
875
+ this.customData.nodes = {};
876
+ }
877
+ if (data === undefined) {
878
+ delete this.customData.nodes[nodeId];
879
+ if (Object.keys(this.customData.nodes).length === 0) {
880
+ delete this.customData.nodes;
881
+ }
882
+ }
883
+ else {
884
+ this.customData.nodes[nodeId] = data;
885
+ }
886
+ this.emit("graphUiChanged", {
887
+ change: { type: "customNodeData", nodeId, data },
888
+ ...options,
889
+ });
890
+ }
891
+ /**
892
+ * Get custom data for a specific edge.
893
+ */
894
+ getCustomEdgeData(edgeId) {
895
+ return this.customData.edges?.[edgeId];
896
+ }
897
+ /**
898
+ * Set custom data for a specific edge.
899
+ */
900
+ setCustomEdgeData(edgeId, data, options) {
901
+ if (!this.customData.edges) {
902
+ this.customData.edges = {};
903
+ }
904
+ if (data === undefined) {
905
+ delete this.customData.edges[edgeId];
906
+ if (Object.keys(this.customData.edges).length === 0) {
907
+ delete this.customData.edges;
908
+ }
909
+ }
910
+ else {
911
+ this.customData.edges[edgeId] = data;
912
+ }
913
+ this.emit("graphUiChanged", {
914
+ change: { type: "customEdgeData", edgeId, data },
915
+ ...options,
916
+ });
917
+ }
918
+ /**
919
+ * Get custom metadata.
920
+ */
921
+ getCustomMetaData() {
922
+ return this.customData.meta;
923
+ }
924
+ /**
925
+ * Set custom metadata.
926
+ */
927
+ setCustomMetaData(meta, options) {
928
+ if (meta === undefined) {
929
+ delete this.customData.meta;
930
+ }
931
+ else {
932
+ this.customData.meta = meta;
933
+ }
934
+ this.emit("graphUiChanged", {
935
+ change: { type: "customMetaData", meta },
936
+ ...options,
937
+ });
938
+ }
939
+ /**
940
+ * Get all custom data.
941
+ */
942
+ getCustomData() {
943
+ return { ...this.customData };
944
+ }
945
+ /**
946
+ * Set all custom data.
947
+ */
948
+ setCustomData(custom, options) {
949
+ if (custom === undefined) {
950
+ this.customData = {};
951
+ }
952
+ else {
953
+ this.customData = lod.pick(custom, ["nodes", "edges", "meta"]);
954
+ }
955
+ this.emit("graphUiChanged", {
956
+ change: {
957
+ type: "customData",
958
+ nodes: custom?.nodes,
959
+ edges: custom?.edges,
960
+ meta: custom?.meta,
961
+ },
962
+ ...options,
963
+ });
964
+ }
852
965
  }
853
966
 
854
967
  class CLIWorkbench {
@@ -909,36 +1022,14 @@ class AbstractGraphRunner {
909
1022
  this.stagedInputs = {};
910
1023
  this.runnerId = "";
911
1024
  }
912
- launch(def, opts) {
913
- // Auto-stop if engine is already running
914
- if (this.engine) {
915
- this.stop();
916
- }
917
- }
918
1025
  async whenIdle() {
919
1026
  await this.engine?.whenIdle();
920
1027
  }
921
- stop() {
922
- if (!this.engine)
923
- return;
924
- // Dispose engine (cleans up timers, listeners, etc.)
925
- this.engine.dispose();
926
- this.engine = undefined;
927
- // Emit status but keep runtime alive
928
- if (this.runMode) {
929
- this.runMode = undefined;
930
- this.emit("status", { running: false, runMode: undefined });
931
- }
932
- }
933
1028
  setRunMode(runMode) {
934
- if (!this.engine) {
935
- throw new Error("Cannot set run mode: engine not running");
1029
+ if (this.engine) {
1030
+ this.engine.setRunMode(runMode);
1031
+ this.emit("status", { running: true, runMode });
936
1032
  }
937
- // Update engine run mode (this will update pause/resume state)
938
- this.engine.setRunMode(runMode);
939
- // Update local state and emit status event
940
- this.runMode = runMode;
941
- this.emit("status", { running: true, runMode: this.runMode });
942
1033
  }
943
1034
  getInputDefaults(def) {
944
1035
  const out = {};
@@ -968,17 +1059,10 @@ class AbstractGraphRunner {
968
1059
  this.engine = undefined;
969
1060
  this.runtime?.dispose();
970
1061
  this.runtime = undefined;
971
- if (this.runMode) {
972
- this.runMode = undefined;
973
- this.emit("status", { running: false, runMode: undefined });
974
- }
975
1062
  }
976
1063
  isRunning() {
977
1064
  return !!this.engine;
978
1065
  }
979
- getRunMode() {
980
- return this.runMode;
981
- }
982
1066
  // Optional undo/redo support
983
1067
  async undo() {
984
1068
  return false;
@@ -997,6 +1081,7 @@ let localRunnerCounter = 0;
997
1081
  class LocalGraphRunner extends AbstractGraphRunner {
998
1082
  constructor(registry) {
999
1083
  super(registry, { kind: "local" });
1084
+ this.extData = {};
1000
1085
  this.setEnvironment = (env, opts) => {
1001
1086
  if (!this.runtime)
1002
1087
  return;
@@ -1045,7 +1130,10 @@ class LocalGraphRunner extends AbstractGraphRunner {
1045
1130
  }
1046
1131
  }
1047
1132
  launch(def, opts) {
1048
- super.launch(def, opts);
1133
+ if (this.engine) {
1134
+ this.engine.dispose();
1135
+ this.engine = undefined;
1136
+ }
1049
1137
  this.build(def);
1050
1138
  if (!this.runtime)
1051
1139
  throw new Error("Runtime not built");
@@ -1072,8 +1160,8 @@ class LocalGraphRunner extends AbstractGraphRunner {
1072
1160
  this.engine.on("invalidate", (e) => this.emit("invalidate", e));
1073
1161
  this.engine.on("stats", (e) => this.emit("stats", e));
1074
1162
  this.engine.launch(opts?.invalidate);
1075
- this.runMode = opts?.runMode ?? "manual";
1076
- this.emit("status", { running: true, runMode: this.runMode });
1163
+ const runMode = opts?.runMode ?? "manual";
1164
+ this.emit("status", { running: true, runMode });
1077
1165
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
1078
1166
  this.engine.setInputs(nodeId, map);
1079
1167
  }
@@ -1167,6 +1255,24 @@ class LocalGraphRunner extends AbstractGraphRunner {
1167
1255
  this.engine.copyOutputs(fromNodeId, toNodeId, options);
1168
1256
  }
1169
1257
  }
1258
+ async setExtData(data) {
1259
+ if (!data || typeof data !== "object") {
1260
+ this.extData = {};
1261
+ return;
1262
+ }
1263
+ this.extData = { ...this.extData, ...data };
1264
+ }
1265
+ async updateExtData(updates) {
1266
+ if (!this.extData ||
1267
+ typeof this.extData !== "object" ||
1268
+ Array.isArray(this.extData)) {
1269
+ this.extData = {};
1270
+ }
1271
+ for (const { path, value } of updates) {
1272
+ const pathSegments = parseJsonPath(path);
1273
+ setValueAtPathWithCreation(this.extData, pathSegments, value);
1274
+ }
1275
+ }
1170
1276
  async snapshotFull() {
1171
1277
  const def = undefined; // UI will supply def/positions on download for local
1172
1278
  const inputs = this.getInputs(this.runtime
@@ -1188,7 +1294,8 @@ class LocalGraphRunner extends AbstractGraphRunner {
1188
1294
  }
1189
1295
  : { nodes: [], edges: [] });
1190
1296
  const environment = this.getEnvironment() || {};
1191
- return { def, environment, inputs, outputs };
1297
+ const extData = this.extData;
1298
+ return { def, environment, inputs, outputs, extData };
1192
1299
  }
1193
1300
  async applySnapshotFull(payload, options) {
1194
1301
  if (payload.def && !options?.skipBuild) {
@@ -1196,6 +1303,9 @@ class LocalGraphRunner extends AbstractGraphRunner {
1196
1303
  }
1197
1304
  this.setEnvironment?.(payload.environment || {}, { merge: false });
1198
1305
  this.hydrate(payload, { dry: options?.dry });
1306
+ if (payload.extData) {
1307
+ await this.setExtData(payload.extData);
1308
+ }
1199
1309
  }
1200
1310
  hydrate(snapshot, opts) {
1201
1311
  // Hydrate via runtime for exact restore (this emits events on runtime emitter)
@@ -1565,7 +1675,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1565
1675
  }
1566
1676
  }
1567
1677
  launch(def, opts) {
1568
- super.launch(def, opts);
1678
+ if (this.engine) {
1679
+ this.engine.dispose();
1680
+ this.engine = undefined;
1681
+ }
1569
1682
  // Remote: build remotely then launch
1570
1683
  this.ensureClient().then(async (client) => {
1571
1684
  await client.api.build(def);
@@ -1621,8 +1734,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1621
1734
  this.listenersBound = true;
1622
1735
  }
1623
1736
  this.engine = eng;
1624
- this.runMode = opts?.runMode ?? "manual";
1625
- this.emit("status", { running: true, runMode: this.runMode });
1737
+ const runMode = opts?.runMode ?? "manual";
1738
+ this.emit("status", { running: true, runMode });
1626
1739
  // Re-apply staged inputs using client.setInputs for consistency
1627
1740
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
1628
1741
  await eng.setInputs(nodeId, map, undefined).catch(() => {
@@ -1638,7 +1751,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1638
1751
  * the runtime state that was just restored.
1639
1752
  */
1640
1753
  launchExisting(def, opts) {
1641
- super.launch(def, opts);
1754
+ if (this.engine) {
1755
+ this.engine.dispose();
1756
+ this.engine = undefined;
1757
+ }
1642
1758
  // Remote: attach to existing runtime and launch (do NOT rebuild)
1643
1759
  this.ensureClient().then(async (client) => {
1644
1760
  // NOTE: We do NOT call client.build(def) here because the backend runtime
@@ -1649,14 +1765,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1649
1765
  });
1650
1766
  }
1651
1767
  setRunMode(runMode) {
1652
- if (!this.engine) {
1653
- throw new Error("Cannot set run mode: engine not running");
1768
+ if (this.engine) {
1769
+ this.engine.setRunMode(runMode);
1770
+ this.emit("status", { running: true, runMode });
1654
1771
  }
1655
- // Update engine run mode (sends SetRunMode command to backend)
1656
- this.engine.setRunMode(runMode);
1657
- // Update local state and emit status event
1658
- this.runMode = runMode;
1659
- this.emit("status", { running: true, runMode: this.runMode });
1660
1772
  }
1661
1773
  async computeNode(nodeId, options) {
1662
1774
  const client = await this.ensureClient();
@@ -1719,6 +1831,10 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1719
1831
  const client = await this.ensureClient();
1720
1832
  await client.api.setExtData(data);
1721
1833
  }
1834
+ async updateExtData(updates) {
1835
+ const client = await this.ensureClient();
1836
+ await client.api.updateExtData(updates);
1837
+ }
1722
1838
  async commit(reason) {
1723
1839
  const client = await this.ensureClient();
1724
1840
  try {
@@ -2590,35 +2706,40 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2590
2706
  }));
2591
2707
  const handleLayout = geom.handleLayout;
2592
2708
  const handles = geom.handles;
2709
+ const baseData = {
2710
+ typeId: n.typeId,
2711
+ params: n.params,
2712
+ inputHandles,
2713
+ outputHandles,
2714
+ inputConnected: Object.fromEntries(inputHandles.map((h) => [
2715
+ h.id,
2716
+ !!connectedInputs[n.nodeId]?.has(h.id),
2717
+ ])),
2718
+ handleLayout,
2719
+ showValues: opts.showValues,
2720
+ renderWidth,
2721
+ renderHeight,
2722
+ initialWidth: initialGeom.width,
2723
+ initialHeight: initialGeom.height,
2724
+ inputValues: opts.inputs?.[n.nodeId],
2725
+ inputDefaults: opts.inputDefaults?.[n.nodeId],
2726
+ outputValues: opts.outputs?.[n.nodeId],
2727
+ status: opts.nodeStatus?.[n.nodeId],
2728
+ validation: {
2729
+ inputs: opts.nodeValidation?.inputs?.[n.nodeId] ?? [],
2730
+ outputs: opts.nodeValidation?.outputs?.[n.nodeId] ?? [],
2731
+ issues: opts.nodeValidation?.issues?.[n.nodeId] ?? [],
2732
+ },
2733
+ toString: opts.toString,
2734
+ toElement: opts.toElement,
2735
+ };
2736
+ const customNodeData = opts.customData?.nodes;
2737
+ const mergedData = customNodeData?.[n.nodeId]
2738
+ ? { ...baseData, custom: customNodeData[n.nodeId] }
2739
+ : baseData;
2593
2740
  return {
2594
2741
  id: n.nodeId,
2595
- data: {
2596
- typeId: n.typeId,
2597
- params: n.params,
2598
- inputHandles,
2599
- outputHandles,
2600
- inputConnected: Object.fromEntries(inputHandles.map((h) => [
2601
- h.id,
2602
- !!connectedInputs[n.nodeId]?.has(h.id),
2603
- ])),
2604
- handleLayout,
2605
- showValues: opts.showValues,
2606
- renderWidth,
2607
- renderHeight,
2608
- initialWidth: initialGeom.width,
2609
- initialHeight: initialGeom.height,
2610
- inputValues: opts.inputs?.[n.nodeId],
2611
- inputDefaults: opts.inputDefaults?.[n.nodeId],
2612
- outputValues: opts.outputs?.[n.nodeId],
2613
- status: opts.nodeStatus?.[n.nodeId],
2614
- validation: {
2615
- inputs: opts.nodeValidation?.inputs?.[n.nodeId] ?? [],
2616
- outputs: opts.nodeValidation?.outputs?.[n.nodeId] ?? [],
2617
- issues: opts.nodeValidation?.issues?.[n.nodeId] ?? [],
2618
- },
2619
- toString: opts.toString,
2620
- toElement: opts.toElement,
2621
- },
2742
+ data: mergedData,
2622
2743
  position: positions[n.nodeId] ?? { x: 0, y: 0 },
2623
2744
  type: opts.resolveNodeType?.(n.typeId) ?? "spark-default",
2624
2745
  selected: opts.selectedNodeIds
@@ -2661,6 +2782,25 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2661
2782
  const targetHandleTypeId = targetHandles?.inputs[e.target.handle]
2662
2783
  ? getInputTypeId(targetHandles.inputs, e.target.handle) ?? "unknown"
2663
2784
  : "unknown";
2785
+ const baseEdgeData = {
2786
+ sourceNodeId: e.source.nodeId,
2787
+ sourceNodeTypeId: sourceNode?.typeId || "unknown",
2788
+ sourceHandle: e.source.handle,
2789
+ sourceHandleTypeId,
2790
+ targetNodeId: e.target.nodeId,
2791
+ targetNodeTypeId: targetNode?.typeId || "unknown",
2792
+ targetHandle: e.target.handle,
2793
+ targetHandleTypeId,
2794
+ edgeTypeId,
2795
+ isRunning,
2796
+ hasError,
2797
+ isInvalid: isInvalidEdge,
2798
+ isMissing,
2799
+ };
2800
+ const customEdgeData = opts.customData?.edges;
2801
+ const mergedEdgeData = customEdgeData?.[e.id]
2802
+ ? { ...baseEdgeData, custom: customEdgeData[e.id] }
2803
+ : baseEdgeData;
2664
2804
  return {
2665
2805
  id: e.id,
2666
2806
  source: e.source.nodeId,
@@ -2674,21 +2814,7 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2674
2814
  style,
2675
2815
  label: edgeTypeId,
2676
2816
  type: "default",
2677
- data: {
2678
- sourceNodeId: e.source.nodeId,
2679
- sourceNodeTypeId: sourceNode?.typeId || "unknown",
2680
- sourceHandle: e.source.handle,
2681
- sourceHandleTypeId,
2682
- targetNodeId: e.target.nodeId,
2683
- targetNodeTypeId: targetNode?.typeId || "unknown",
2684
- targetHandle: e.target.handle,
2685
- targetHandleTypeId,
2686
- edgeTypeId,
2687
- isRunning,
2688
- hasError,
2689
- isInvalid: isInvalidEdge,
2690
- isMissing,
2691
- },
2817
+ data: mergedEdgeData,
2692
2818
  };
2693
2819
  });
2694
2820
  return { nodes, edges };
@@ -2844,6 +2970,9 @@ async function upload(parsed, wb, runner) {
2844
2970
  if (extData.runtime && typeof extData.runtime === "object") {
2845
2971
  wb.setRuntimeState(extData.runtime);
2846
2972
  }
2973
+ if (extData.custom && typeof extData.custom === "object") {
2974
+ wb.setCustomData(extData.custom);
2975
+ }
2847
2976
  if (runner.isRunning()) {
2848
2977
  await runner.applySnapshotFull({
2849
2978
  def: wb.def,
@@ -2860,6 +2989,9 @@ async function upload(parsed, wb, runner) {
2860
2989
  runner.setInputs(nodeId, map, { dry: true });
2861
2990
  }
2862
2991
  }
2992
+ if (extData) {
2993
+ await runner.setExtData(extData);
2994
+ }
2863
2995
  }
2864
2996
  }
2865
2997
 
@@ -3656,15 +3788,7 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
3656
3788
  const graphUiTick = useWorkbenchGraphUiTick(wb);
3657
3789
  const versionTick = useWorkbenchVersionTick(runner);
3658
3790
  const valuesTick = versionTick + graphTick + graphUiTick;
3659
- // Keep local runMode state loosely in sync with runner status.
3660
- // - Seed from runner.getRunMode() on mount if available.
3661
- // - On status events, update only when a non-undefined runMode is reported,
3662
- // so the UI preserves the last selected mode after stop().
3663
3791
  useEffect(() => {
3664
- const initialMode = runner.getRunMode();
3665
- if (initialMode) {
3666
- setRunModeState(initialMode);
3667
- }
3668
3792
  const offRunnerStatus = runner.on("status", (status) => {
3669
3793
  if (status.runMode) {
3670
3794
  setRunModeState(status.runMode);
@@ -3824,12 +3948,13 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
3824
3948
  workbench.setRuntimeState(metadata);
3825
3949
  const fullUiState = workbench.getUIState();
3826
3950
  const uiWithoutViewport = excludeViewportFromUIState(fullUiState);
3827
- await graphRunner.setExtData?.({
3828
- ...(Object.keys(uiWithoutViewport || {}).length > 0
3829
- ? { ui: uiWithoutViewport }
3830
- : {}),
3831
- runtime: metadata,
3832
- });
3951
+ // Use updateExtData for efficient batched partial updates
3952
+ const updates = [];
3953
+ if (Object.keys(uiWithoutViewport || {}).length > 0) {
3954
+ updates.push({ path: "ui", value: uiWithoutViewport });
3955
+ }
3956
+ updates.push({ path: "runtime", value: metadata });
3957
+ await graphRunner.updateExtData(updates);
3833
3958
  }
3834
3959
  catch (err) {
3835
3960
  console.warn("[WorkbenchContext] Failed to save runtime metadata:", err);
@@ -4262,6 +4387,18 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4262
4387
  else if (changeType === "selection") {
4263
4388
  reason = "selection";
4264
4389
  }
4390
+ else if (changeType === "customNodeData") {
4391
+ reason = "custom-node-data";
4392
+ }
4393
+ else if (changeType === "customEdgeData") {
4394
+ reason = "custom-edge-data";
4395
+ }
4396
+ else if (changeType === "customMetaData") {
4397
+ reason = "custom-meta";
4398
+ }
4399
+ else if (changeType === "customData") {
4400
+ reason = "custom";
4401
+ }
4265
4402
  }
4266
4403
  await saveUiRuntimeMetadata(wb, runner);
4267
4404
  const history = await runner
@@ -4367,27 +4504,11 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4367
4504
  };
4368
4505
  }, [runner, wb]);
4369
4506
  const isRunning = useCallback(() => runner.isRunning(), [runner]);
4370
- const getRunMode = useCallback(() => runner.getRunMode(), [runner]);
4371
- const stop = useCallback(() => runner.stop(), [runner]);
4372
- // Run mode actions
4373
4507
  const setRunMode = useCallback((mode) => {
4374
4508
  if (mode === runMode)
4375
4509
  return;
4376
- const wasRunning = runner.isRunning();
4377
- if (wasRunning) {
4378
- // Use setRunMode to change run mode without rebuilding
4379
- try {
4380
- runner.setRunMode(mode);
4381
- setRunModeState(mode);
4382
- }
4383
- catch (err) {
4384
- console.error("Failed to set run mode:", err);
4385
- }
4386
- }
4387
- else {
4388
- // Just update state if not running (will be applied on next launch)
4389
- setRunModeState(mode);
4390
- }
4510
+ runner.setRunMode(mode);
4511
+ setRunModeState(mode);
4391
4512
  }, [runMode, runner]);
4392
4513
  const runNodeAction = useCallback(async (nodeId) => {
4393
4514
  await runner.computeNode(nodeId);
@@ -4489,8 +4610,6 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4489
4610
  removeRegistryError,
4490
4611
  removeInputValidationError,
4491
4612
  isRunning,
4492
- getRunMode,
4493
- stop,
4494
4613
  runMode,
4495
4614
  setRunMode,
4496
4615
  runNode: runNodeAction,
@@ -4531,8 +4650,6 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4531
4650
  events,
4532
4651
  clearEvents,
4533
4652
  isRunning,
4534
- getRunMode,
4535
- stop,
4536
4653
  runMode,
4537
4654
  setRunMode,
4538
4655
  runNodeAction,
@@ -5399,7 +5516,7 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
5399
5516
  !handlers.onRedo && jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsx("div", { className: "px-2 pb-1", children: 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() }) }), jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
5400
5517
  }
5401
5518
 
5402
- function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, runMode, enableKeyboardShortcuts = true, keyboardShortcuts = {
5519
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, runMode, wb, enableKeyboardShortcuts = true, keyboardShortcuts = {
5403
5520
  copy: "⌘/Ctrl + C",
5404
5521
  duplicate: "⌘/Ctrl + E",
5405
5522
  duplicateWithEdges: "⌘/Ctrl + Shift + E",
@@ -5433,6 +5550,10 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, r
5433
5550
  }, [open]);
5434
5551
  if (!open || !clientPos || !nodeId)
5435
5552
  return null;
5553
+ // Determine if this is a start node (no inbound edges)
5554
+ const isStartNode = wb
5555
+ ? !wb.def.edges.some((e) => e.target.nodeId === nodeId)
5556
+ : false;
5436
5557
  // clamp
5437
5558
  const MENU_MIN_WIDTH = 180;
5438
5559
  const PADDING = 16;
@@ -5442,7 +5563,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, bakeableOutputs, r
5442
5563
  return (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) => {
5443
5564
  e.preventDefault();
5444
5565
  e.stopPropagation();
5445
- }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx(ContextMenuButton, { label: "Duplicate", onClick: handlers.onDuplicate, shortcut: keyboardShortcuts.duplicate, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx(ContextMenuButton, { label: "Duplicate with edges", onClick: handlers.onDuplicateWithEdges, shortcut: keyboardShortcuts.duplicateWithEdges, enableKeyboardShortcuts: enableKeyboardShortcuts }), runMode === "manual" && (jsxs(Fragment, { children: [handlers.onRunNode && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunNode, children: "Run node" })), handlers.onRunFromHere && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunFromHere, children: "Run from here" }))] })), jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), 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 && (jsxs(Fragment, { children: [jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
5566
+ }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx(ContextMenuButton, { label: "Duplicate", onClick: handlers.onDuplicate, shortcut: keyboardShortcuts.duplicate, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx(ContextMenuButton, { label: "Duplicate with edges", onClick: handlers.onDuplicateWithEdges, shortcut: keyboardShortcuts.duplicateWithEdges, enableKeyboardShortcuts: enableKeyboardShortcuts }), runMode === "manual" && (jsxs(Fragment, { children: [handlers.onRunNode && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunNode, children: "Run node" })), handlers.onRunFromHere && (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" }))] })), jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), 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 && (jsxs(Fragment, { children: [jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
5446
5567
  }
5447
5568
 
5448
5569
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -5528,7 +5649,56 @@ function useKeyboardShortcutToast() {
5528
5649
  return { toast, showToast, hideToast };
5529
5650
  }
5530
5651
 
5531
- const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
5652
+ const SelectionBoundOverlay = ({ selection, rfInstance }) => {
5653
+ const selectionBounds = useMemo(() => {
5654
+ if (typeof document === "undefined" ||
5655
+ !rfInstance ||
5656
+ selection.nodes.length < 2) {
5657
+ return null;
5658
+ }
5659
+ let bounds = null;
5660
+ for (const nodeId of selection.nodes) {
5661
+ const el = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`);
5662
+ if (!el)
5663
+ continue;
5664
+ const rect = el.getBoundingClientRect();
5665
+ if (!bounds) {
5666
+ bounds = {
5667
+ left: rect.left,
5668
+ top: rect.top,
5669
+ right: rect.right,
5670
+ bottom: rect.bottom,
5671
+ };
5672
+ }
5673
+ else {
5674
+ bounds.left = Math.min(bounds.left, rect.left);
5675
+ bounds.top = Math.min(bounds.top, rect.top);
5676
+ bounds.right = Math.max(bounds.right, rect.right);
5677
+ bounds.bottom = Math.max(bounds.bottom, rect.bottom);
5678
+ }
5679
+ }
5680
+ return bounds;
5681
+ }, [selection.nodes, rfInstance]);
5682
+ if (!selectionBounds || selection.nodes.length < 2) {
5683
+ return null;
5684
+ }
5685
+ const { left, top, right, bottom } = selectionBounds;
5686
+ const width = right - left;
5687
+ const height = bottom - top;
5688
+ return (jsx("div", { style: {
5689
+ position: "fixed",
5690
+ left: `${left}px`,
5691
+ top: `${top}px`,
5692
+ width: `${width}px`,
5693
+ height: `${height}px`,
5694
+ border: "1px dashed #0ea5e9",
5695
+ pointerEvents: "none",
5696
+ zIndex: 4,
5697
+ boxSizing: "border-box",
5698
+ } }));
5699
+ };
5700
+
5701
+ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize, reactFlowProps }, ref) => {
5532
5702
  const { wb, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, uiVersion, registryVersion, runner, overrides, runNode, runFromHere, runMode, } = useWorkbenchContext();
5533
5703
  const nodeValidation = validationByNode;
5534
5704
  const edgeValidation = validationByEdge.errors;
@@ -5566,6 +5736,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5566
5736
  status: n.data.status,
5567
5737
  validation: n.data.validation,
5568
5738
  inputConnected: n.data.inputConnected,
5739
+ custom: n.data.custom,
5569
5740
  },
5570
5741
  });
5571
5742
  return lod.isEqual(pick(a), pick(b));
@@ -5581,6 +5752,14 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5581
5752
  style: e.style,
5582
5753
  label: e.label,
5583
5754
  type: e.type,
5755
+ data: e.data && {
5756
+ edgeTypeId: e.data.edgeTypeId,
5757
+ isRunning: e.data.isRunning,
5758
+ hasError: e.data.hasError,
5759
+ isInvalid: e.data.isInvalid,
5760
+ isMissing: e.data.isMissing,
5761
+ custom: e.data.custom,
5762
+ },
5584
5763
  });
5585
5764
  return lod.isEqual(pick(a), pick(b));
5586
5765
  };
@@ -5634,27 +5813,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5634
5813
  }, [uiVersion, ui]);
5635
5814
  const { nodes, edges } = useMemo(() => {
5636
5815
  const sel = wb.getSelection();
5637
- // Merge defaults with inputs for node display (defaults shown in lighter gray)
5638
- const inputsWithDefaults = {};
5639
- for (const n of wb.def.nodes) {
5640
- const nodeInputs = inputsMap[n.nodeId] ?? {};
5641
- const nodeDefaults = inputDefaultsMap[n.nodeId] ?? {};
5642
- const inbound = new Set(wb.def.edges
5643
- .filter((e) => e.target.nodeId === n.nodeId)
5644
- .map((e) => e.target.handle));
5645
- const merged = { ...nodeInputs };
5646
- for (const [h, v] of Object.entries(nodeDefaults)) {
5647
- if (!inbound.has(h) && merged[h] === undefined) {
5648
- merged[h] = v;
5649
- }
5650
- }
5651
- if (Object.keys(merged).length > 0) {
5652
- inputsWithDefaults[n.nodeId] = merged;
5653
- }
5654
- }
5655
5816
  const out = toReactFlow(wb.def, wb.getPositions(), wb.getSizes(), wb.registry, {
5656
5817
  showValues,
5657
- inputs: inputsWithDefaults,
5818
+ inputs: inputsMap,
5658
5819
  inputDefaults: inputDefaultsMap,
5659
5820
  outputs: outputsMap,
5660
5821
  resolveNodeType,
@@ -5668,6 +5829,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5668
5829
  selectedEdgeIds: new Set(sel.edges),
5669
5830
  getDefaultNodeSize,
5670
5831
  ui,
5832
+ customData: wb.getCustomData(),
5671
5833
  });
5672
5834
  // Retain references for unchanged items
5673
5835
  const stableNodes = retainStabilityById(prevNodesRef.current, out.nodes, isSameNode);
@@ -5772,13 +5934,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5772
5934
  resolveNodeType,
5773
5935
  ]);
5774
5936
  const throttled = useThrottledValue({ nodes, edges }, 100);
5775
- const [menuOpen, setMenuOpen] = useState(false);
5776
- const [menuPos, setMenuPos] = useState(null);
5777
- const [nodeMenuOpen, setNodeMenuOpen] = useState(false);
5778
- const [nodeMenuPos, setNodeMenuPos] = useState(null);
5779
- const [nodeAtMenu, setNodeAtMenu] = useState(null);
5780
- const [selectionMenuPos, setSelectionMenuPos] = useState(null);
5781
- const [selectionMenuOpen, setSelectionMenuOpen] = useState(false);
5937
+ const [menuState, setMenuState] = useState(null);
5782
5938
  // Compute the rectangular screen-space bounds of the current selection
5783
5939
  const getSelectionScreenBounds = () => {
5784
5940
  if (typeof document === "undefined")
@@ -5819,31 +5975,24 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5819
5975
  if (target) {
5820
5976
  // Resolve node id from data-id attribute React Flow sets
5821
5977
  const nodeId = target.getAttribute("data-id");
5822
- const isSelected = nodeId && selection.nodes.includes(nodeId);
5823
- if (isSelected && isSingleNodeSelected) {
5824
- // Right-clicked on the single selected node - show node menu
5825
- setNodeAtMenu(nodeId);
5826
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5827
- setNodeMenuOpen(true);
5828
- setMenuOpen(false);
5829
- setSelectionMenuOpen(false);
5978
+ if (!nodeId)
5830
5979
  return;
5831
- }
5832
- else if (isSelected) {
5980
+ const isSelected = selection.nodes.includes(nodeId);
5981
+ if (isSelected && !isSingleNodeSelected) {
5833
5982
  // Right-clicked on a node that's part of multi-selection - show selection menu
5834
- setSelectionMenuPos({ x: e.clientX, y: e.clientY });
5835
- setSelectionMenuOpen(true);
5836
- setMenuOpen(false);
5837
- setNodeMenuOpen(false);
5983
+ setMenuState({
5984
+ type: "selection",
5985
+ menuPos: { x: e.clientX, y: e.clientY },
5986
+ });
5838
5987
  return;
5839
5988
  }
5840
5989
  else {
5841
5990
  // Right-clicked on a non-selected node - show node menu
5842
- setNodeAtMenu(nodeId);
5843
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5844
- setNodeMenuOpen(true);
5845
- setMenuOpen(false);
5846
- setSelectionMenuOpen(false);
5991
+ setMenuState({
5992
+ type: "node",
5993
+ menuPos: { x: e.clientX, y: e.clientY },
5994
+ nodeId,
5995
+ });
5847
5996
  return;
5848
5997
  }
5849
5998
  }
@@ -5855,32 +6004,22 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5855
6004
  if (isSelected && isSingleNodeSelected) {
5856
6005
  // Right-clicked on an edge, but only one node is selected - show node menu
5857
6006
  const nodeId = selection.nodes[0];
5858
- setNodeAtMenu(nodeId);
5859
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5860
- setNodeMenuOpen(true);
5861
- setMenuOpen(false);
5862
- setSelectionMenuOpen(false);
6007
+ setMenuState({
6008
+ type: "node",
6009
+ menuPos: { x: e.clientX, y: e.clientY },
6010
+ nodeId,
6011
+ });
5863
6012
  return;
5864
6013
  }
5865
6014
  else if (isSelected) {
5866
6015
  // Right-clicked on a selected edge with multiple nodes - show selection menu
5867
- setSelectionMenuPos({ x: e.clientX, y: e.clientY });
5868
- setSelectionMenuOpen(true);
5869
- setMenuOpen(false);
5870
- setNodeMenuOpen(false);
6016
+ setMenuState({
6017
+ type: "selection",
6018
+ menuPos: { x: e.clientX, y: e.clientY },
6019
+ });
5871
6020
  return;
5872
6021
  }
5873
6022
  }
5874
- // If only one node is selected (even with edges), show node menu for empty space clicks
5875
- if (isSingleNodeSelected) {
5876
- const nodeId = selection.nodes[0];
5877
- setNodeAtMenu(nodeId);
5878
- setNodeMenuPos({ x: e.clientX, y: e.clientY });
5879
- setNodeMenuOpen(true);
5880
- setMenuOpen(false);
5881
- setSelectionMenuOpen(false);
5882
- return;
5883
- }
5884
6023
  // Check if the cursor is inside the rectangular bounds of the current selection
5885
6024
  // (for multi-selection when right-clicking on empty space within selection bounds)
5886
6025
  const selectionBounds = getSelectionScreenBounds();
@@ -5890,28 +6029,38 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5890
6029
  e.clientX <= right &&
5891
6030
  e.clientY >= top &&
5892
6031
  e.clientY <= bottom) {
5893
- setSelectionMenuPos({ x: e.clientX, y: e.clientY });
5894
- setSelectionMenuOpen(true);
5895
- setMenuOpen(false);
5896
- setNodeMenuOpen(false);
6032
+ // If only one node is selected (even with edges), show node menu for empty space clicks
6033
+ if (isSingleNodeSelected) {
6034
+ const nodeId = selection.nodes[0];
6035
+ setMenuState({
6036
+ type: "node",
6037
+ menuPos: { x: e.clientX, y: e.clientY },
6038
+ nodeId,
6039
+ });
6040
+ return;
6041
+ }
6042
+ setMenuState({
6043
+ type: "selection",
6044
+ menuPos: { x: e.clientX, y: e.clientY },
6045
+ });
5897
6046
  return;
5898
6047
  }
5899
6048
  }
5900
6049
  // Right-clicked on empty space with no selection - show default menu
5901
- setMenuPos({ x: e.clientX, y: e.clientY });
5902
- setMenuOpen(true);
5903
- setNodeMenuOpen(false);
5904
- setSelectionMenuOpen(false);
6050
+ setMenuState({
6051
+ type: "default",
6052
+ menuPos: { x: e.clientX, y: e.clientY },
6053
+ });
5905
6054
  };
5906
6055
  const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId }, { inputs: opts.inputs, position: opts.position, commit: true }), [wb]);
5907
6056
  const onCloseMenu = useCallback(() => {
5908
- setMenuOpen(false);
6057
+ setMenuState(null);
5909
6058
  }, []);
5910
6059
  const onCloseNodeMenu = useCallback(() => {
5911
- setNodeMenuOpen(false);
6060
+ setMenuState(null);
5912
6061
  }, []);
5913
6062
  const onCloseSelectionMenu = useCallback(() => {
5914
- setSelectionMenuOpen(false);
6063
+ setMenuState(null);
5915
6064
  }, []);
5916
6065
  useEffect(() => {
5917
6066
  const off = wb.on("historyChanged", (event) => {
@@ -5958,8 +6107,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5958
6107
  return baseHandlers;
5959
6108
  }, [wb, runner, overrides, onCloseSelectionMenu]);
5960
6109
  const nodeContextMenuHandlers = useMemo(() => {
5961
- if (!nodeAtMenu)
6110
+ if (menuState?.type !== "node")
5962
6111
  return null;
6112
+ const nodeAtMenu = menuState.nodeId;
5963
6113
  // Get storage from override or use workbench's internal storage
5964
6114
  const storage = overrides?.getCopiedDataStorage
5965
6115
  ? overrides.getCopiedDataStorage()
@@ -5975,7 +6125,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5975
6125
  }
5976
6126
  return baseHandlers;
5977
6127
  }, [
5978
- nodeAtMenu,
6128
+ menuState,
5979
6129
  wb,
5980
6130
  runner,
5981
6131
  wb.registry,
@@ -5988,10 +6138,10 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5988
6138
  overrides?.getCopiedDataStorage,
5989
6139
  ]);
5990
6140
  const bakeableOutputs = useMemo(() => {
5991
- if (!nodeAtMenu)
6141
+ if (menuState?.type !== "node")
5992
6142
  return [];
5993
- return getBakeableOutputs(nodeAtMenu, wb, wb.registry, outputTypesMap);
5994
- }, [nodeAtMenu, wb, wb.registry, registryVersion, outputTypesMap]);
6143
+ return getBakeableOutputs(menuState.nodeId, wb, wb.registry, outputTypesMap);
6144
+ }, [menuState, wb, wb.registry, registryVersion, outputTypesMap]);
5995
6145
  // Keyboard shortcuts configuration
5996
6146
  const enableKeyboardShortcuts = overrides?.enableKeyboardShortcuts !== false; // Default to true
5997
6147
  const keyboardShortcuts = overrides?.keyboardShortcuts || {
@@ -6061,7 +6211,8 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
6061
6211
  const modKeyLabel = isMac ? "⌘" : "Ctrl";
6062
6212
  showToast(`Copy (${modKeyLabel} + C)`);
6063
6213
  // If single node selected, use node context menu handler; otherwise use selection handler
6064
- if (selection.nodes.length === 1 && nodeContextMenuHandlers?.onCopy) {
6214
+ if (selection.nodes.length === 1 &&
6215
+ nodeContextMenuHandlers?.onCopy) {
6065
6216
  nodeContextMenuHandlers.onCopy();
6066
6217
  }
6067
6218
  else if (selectionContextMenuHandlers.onCopy) {
@@ -6181,21 +6332,25 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
6181
6332
  });
6182
6333
  return () => off();
6183
6334
  }, [wb]);
6184
- return (jsxs("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: [jsx(ReactFlowProvider, { children: jsxs(ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, connectionLineComponent: connectionLineRenderer, selectionOnDrag: true, onInit: (inst) => {
6185
- rfInstanceRef.current = inst;
6186
- const savedViewport = wb.getViewport();
6187
- if (savedViewport) {
6188
- inst.setViewport(lod.clone(savedViewport));
6189
- }
6190
- }, 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 ? (jsx(BackgroundRenderer, {})) : (jsx(Background, { id: "workbench-canvas-background", variant: BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsx(MinimapRenderer, {}) : jsx(MiniMap, {}), ControlsRenderer ? jsx(ControlsRenderer, {}) : jsx(Controls, {}), DefaultContextMenuRenderer ? (jsx(DefaultContextMenuRenderer, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, ...(enableKeyboardShortcuts !== false
6191
- ? { enableKeyboardShortcuts, keyboardShortcuts }
6192
- : {}) })) : (jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
6193
- nodeContextMenuHandlers &&
6194
- (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode, wb: wb, ...(enableKeyboardShortcuts !== false
6195
- ? { enableKeyboardShortcuts, keyboardShortcuts }
6196
- : {}) })) : (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode }))), selectionMenuOpen &&
6197
- selectionMenuPos &&
6198
- (SelectionContextMenuRenderer ? (jsx(SelectionContextMenuRenderer, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })) : (jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })))] }) }), toast && (jsx(KeyboardShortcutToast, { message: toast.message, onClose: hideToast }, toast.id))] }));
6335
+ const { onInit: userOnInit, ...restReactFlowProps } = reactFlowProps || {};
6336
+ return (jsxs("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: [jsxs(ReactFlowProvider, { children: [jsxs(ReactFlow, { ...restReactFlowProps, nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, connectionLineComponent: connectionLineRenderer, selectionOnDrag: true, onInit: (inst) => {
6337
+ rfInstanceRef.current = inst;
6338
+ const savedViewport = wb.getViewport();
6339
+ if (savedViewport) {
6340
+ inst.setViewport(lod.clone(savedViewport));
6341
+ }
6342
+ if (userOnInit) {
6343
+ userOnInit(inst);
6344
+ }
6345
+ }, 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 ? (jsx(BackgroundRenderer, {})) : (jsx(Background, { id: "workbench-canvas-background", variant: BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsx(MinimapRenderer, {}) : jsx(MiniMap, {}), ControlsRenderer ? jsx(ControlsRenderer, {}) : jsx(Controls, {}), menuState?.type === "default" &&
6346
+ (DefaultContextMenuRenderer ? (jsx(DefaultContextMenuRenderer, { open: true, clientPos: menuState.menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, ...(enableKeyboardShortcuts !== false
6347
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
6348
+ : {}) })) : (jsx(DefaultContextMenu, { open: true, clientPos: menuState.menuPos, handlers: defaultContextMenuHandlers, registry: wb.registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))), menuState?.type === "node" &&
6349
+ nodeContextMenuHandlers &&
6350
+ (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: true, clientPos: menuState.menuPos, nodeId: menuState.nodeId, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode, wb: wb, ...(enableKeyboardShortcuts !== false
6351
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
6352
+ : {}) })) : (jsx(NodeContextMenu, { open: true, clientPos: menuState.menuPos, nodeId: menuState.nodeId, handlers: nodeContextMenuHandlers, bakeableOutputs: bakeableOutputs, runMode: runMode }))), menuState?.type === "selection" &&
6353
+ (SelectionContextMenuRenderer ? (jsx(SelectionContextMenuRenderer, { open: true, clientPos: menuState.menuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })) : (jsx(SelectionContextMenu, { open: true, clientPos: menuState.menuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })))] }), jsx(SelectionBoundOverlay, { selection: wb.getSelection(), rfInstance: rfInstanceRef.current })] }), toast && (jsx(KeyboardShortcutToast, { message: toast.message, onClose: hideToast }, toast.id))] }));
6199
6354
  });
6200
6355
 
6201
6356
  function WorkbenchStudioCanvas({ autoScroll, onAutoScrollChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
@@ -6223,9 +6378,6 @@ function WorkbenchStudioCanvas({ autoScroll, onAutoScrollChange, example, onExam
6223
6378
  if (isConnecting) {
6224
6379
  return (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: [jsx(ClockClockwiseIcon, { size: 16, className: "animate-spin" }), jsx("span", { className: "font-medium ml-1", children: "Connecting..." })] }));
6225
6380
  }
6226
- if (isGraphRunning) {
6227
- return (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: [jsx(StopIcon, { size: 16, weight: "fill" }), jsx("span", { className: "font-medium ml-1", children: "Stop" })] }));
6228
- }
6229
6381
  return (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) => {
6230
6382
  if (evt.shiftKey && !confirm("Invalidate and re-run graph?"))
6231
6383
  return;
@@ -6612,11 +6764,12 @@ function WorkbenchStudioCanvas({ autoScroll, onAutoScrollChange, example, onExam
6612
6764
  if (mode !== runMode) {
6613
6765
  await setRunMode(mode);
6614
6766
  }
6615
- }, disabled: isGraphRunning, title: isGraphRunning
6616
- ? "Stop before switching run mode"
6617
- : "Select run mode", children: [jsx("option", { value: "manual", children: "Manual" }), jsx("option", { value: "auto", children: "Auto" })] }), renderStartStopButton(), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsx(TreeStructureIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsx(CornersOutIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsx(DownloadIcon, { size: 24 }) }), jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsx(UploadIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
6767
+ }, title: "Select run mode", children: [jsx("option", { value: "manual", children: "Manual" }), jsx("option", { value: "auto", children: "Auto" })] }), renderStartStopButton(), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsx(TreeStructureIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsx(CornersOutIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsx(DownloadIcon, { size: 24 }) }), jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsx(UploadIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
6618
6768
  await downloadCanvasThumbnail(canvasContainerRef.current);
6619
- }, title: "Download Flow Thumbnail (SVG)", children: jsx(ImageIcon, { size: 24 }) }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsx(BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsx(ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxs("div", { className: "flex flex-1 min-h-0", children: [jsx("div", { className: "flex-1 min-w-0", ref: canvasContainerRef, children: jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
6769
+ }, title: "Download Flow Thumbnail (SVG)", children: jsx(ImageIcon, { size: 24 }) }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsx(BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsx(ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxs("div", { className: "flex flex-1 min-h-0", children: [jsx("div", { className: "flex-1 min-w-0", ref: canvasContainerRef, children: jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize, reactFlowProps: {
6770
+ minZoom: 0.1,
6771
+ maxZoom: 5,
6772
+ } }) }), jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
6620
6773
  }
6621
6774
  function WorkbenchStudio({ example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
6622
6775
  const [registry, setRegistry] = useState(createSimpleGraphRegistry());