@bian-womp/spark-workbench 0.2.62 → 0.2.64

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 (45) hide show
  1. package/lib/cjs/index.cjs +236 -112
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/AbstractWorkbench.d.ts +3 -1
  4. package/lib/cjs/src/core/AbstractWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +6 -0
  6. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  7. package/lib/cjs/src/core/contracts.d.ts +7 -0
  8. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  10. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts +1 -0
  12. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  15. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +12 -9
  16. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  17. package/lib/cjs/src/runtime/IGraphRunner.d.ts +7 -3
  18. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  19. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts +9 -0
  20. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  21. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +11 -4
  22. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  23. package/lib/esm/index.js +236 -112
  24. package/lib/esm/index.js.map +1 -1
  25. package/lib/esm/src/core/AbstractWorkbench.d.ts +3 -1
  26. package/lib/esm/src/core/AbstractWorkbench.d.ts.map +1 -1
  27. package/lib/esm/src/core/InMemoryWorkbench.d.ts +6 -0
  28. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  29. package/lib/esm/src/core/contracts.d.ts +7 -0
  30. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  31. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  32. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  33. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts +1 -0
  34. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  35. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  36. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  37. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +12 -9
  38. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  39. package/lib/esm/src/runtime/IGraphRunner.d.ts +7 -3
  40. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  41. package/lib/esm/src/runtime/LocalGraphRunner.d.ts +9 -0
  42. package/lib/esm/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  43. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +11 -4
  44. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  45. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -190,7 +190,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
190
190
  }
191
191
  return { ok: issues.every((i) => i.level !== "error"), issues };
192
192
  }
193
- addNode(node) {
193
+ addNode(node, options) {
194
194
  const id = node.nodeId ??
195
195
  this.genId("n", new Set(this.def.nodes.map((n) => n.nodeId)));
196
196
  this.def.nodes.push({
@@ -203,7 +203,13 @@ class InMemoryWorkbench extends AbstractWorkbench {
203
203
  this.positions[id] = node.position;
204
204
  this.emit("graphChanged", {
205
205
  def: this.def,
206
- change: { type: "addNode", nodeId: id },
206
+ change: {
207
+ type: "addNode",
208
+ nodeId: id,
209
+ inputs: options?.inputs,
210
+ copyOutputsFrom: options?.copyOutputsFrom,
211
+ },
212
+ dry: options?.dry,
207
213
  });
208
214
  this.refreshValidation();
209
215
  return id;
@@ -218,7 +224,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
218
224
  });
219
225
  this.refreshValidation();
220
226
  }
221
- connect(edge) {
227
+ connect(edge, options) {
222
228
  const id = edge.id ?? this.genId("e", new Set(this.def.edges.map((e) => e.id)));
223
229
  this.def.edges.push({
224
230
  id,
@@ -229,6 +235,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
229
235
  this.emit("graphChanged", {
230
236
  def: this.def,
231
237
  change: { type: "connect", edgeId: id },
238
+ dry: options?.dry,
232
239
  });
233
240
  this.refreshValidation();
234
241
  return id;
@@ -426,35 +433,6 @@ class AbstractGraphRunner {
426
433
  this.stop();
427
434
  }
428
435
  }
429
- triggerExternal(nodeId, event, options) {
430
- this.engine?.triggerExternal(nodeId, event);
431
- }
432
- // Batch update multiple inputs on a node and trigger a single run
433
- setInputs(nodeId, inputs, options) {
434
- if (!inputs)
435
- return;
436
- if (!this.stagedInputs[nodeId])
437
- this.stagedInputs[nodeId] = {};
438
- for (const [handle, value] of Object.entries(inputs)) {
439
- if (value === undefined) {
440
- delete this.stagedInputs[nodeId][handle];
441
- }
442
- else {
443
- this.stagedInputs[nodeId][handle] = value;
444
- }
445
- }
446
- if (this.engine) {
447
- // Running: set all inputs
448
- this.engine.setInputs(nodeId, inputs);
449
- }
450
- else {
451
- // Not running: emit a single synthetic value event per handle; UI will coalesce
452
- console.warn("Engine does not exists");
453
- for (const [handle, value] of Object.entries(inputs)) {
454
- this.emit("value", { nodeId, handle, value, io: "input" });
455
- }
456
- }
457
- }
458
436
  async whenIdle() {
459
437
  await this.engine?.whenIdle();
460
438
  }
@@ -577,15 +555,19 @@ class LocalGraphRunner extends AbstractGraphRunner {
577
555
  if (!this.runtime)
578
556
  return;
579
557
  const wasPaused = this.runtime.isPaused();
580
- if (!wasPaused)
558
+ // Pause runtime if dry option is set (to prevent execution) or if not paused already
559
+ if (options?.dry && !wasPaused) {
581
560
  this.runtime.pause();
561
+ }
582
562
  try {
583
563
  this.runtime.update(def, this.registry);
584
564
  this.emit("invalidate", { reason: "graph-updated" });
585
565
  }
586
566
  finally {
587
- if (!wasPaused)
567
+ // Resume only if we paused it due to dry option
568
+ if (options?.dry && !wasPaused) {
588
569
  this.runtime.resume();
570
+ }
589
571
  }
590
572
  }
591
573
  launch(def, opts) {
@@ -677,6 +659,70 @@ class LocalGraphRunner extends AbstractGraphRunner {
677
659
  }
678
660
  return out;
679
661
  }
662
+ triggerExternal(nodeId, event, options) {
663
+ // Handle dry option: pause runtime before triggering, resume after
664
+ const wasPaused = this.runtime?.isPaused() ?? false;
665
+ if (options?.dry && !wasPaused && this.runtime) {
666
+ this.runtime.pause();
667
+ }
668
+ try {
669
+ this.engine?.triggerExternal(nodeId, event);
670
+ }
671
+ finally {
672
+ if (options?.dry && !wasPaused && this.runtime) {
673
+ this.runtime.resume();
674
+ }
675
+ }
676
+ }
677
+ // Batch update multiple inputs on a node and trigger a single run
678
+ setInputs(nodeId, inputs, options) {
679
+ if (!inputs)
680
+ return;
681
+ if (!this.stagedInputs[nodeId])
682
+ this.stagedInputs[nodeId] = {};
683
+ for (const [handle, value] of Object.entries(inputs)) {
684
+ if (value === undefined) {
685
+ delete this.stagedInputs[nodeId][handle];
686
+ }
687
+ else {
688
+ this.stagedInputs[nodeId][handle] = value;
689
+ }
690
+ }
691
+ // Handle dry option: pause runtime before setting inputs, resume after
692
+ const wasPaused = this.runtime?.isPaused() ?? false;
693
+ if (options?.dry && !wasPaused && this.runtime) {
694
+ this.runtime.pause();
695
+ }
696
+ try {
697
+ if (this.engine) {
698
+ this.engine.setInputs(nodeId, inputs);
699
+ }
700
+ else {
701
+ // Not running: emit a single synthetic value event per handle; UI will coalesce
702
+ console.warn("Engine does not exists");
703
+ for (const [handle, value] of Object.entries(inputs)) {
704
+ this.emit("value", { nodeId, handle, value, io: "input" });
705
+ }
706
+ }
707
+ }
708
+ finally {
709
+ if (options?.dry && !wasPaused && this.runtime) {
710
+ this.runtime.resume();
711
+ }
712
+ }
713
+ }
714
+ copyOutputs(fromNodeId, toNodeId, options) {
715
+ if (!this.runtime)
716
+ return;
717
+ // Get outputs from source node
718
+ const fromNode = this.runtime.getNodeData(fromNodeId);
719
+ if (!fromNode?.outputs)
720
+ return;
721
+ // Copy outputs to target node using hydrate
722
+ // hydrate already pauses internally, so we don't need to handle dry option here
723
+ // reemit: !options?.dry means don't propagate downstream if dry mode
724
+ this.runtime.hydrate({ outputs: { [toNodeId]: { ...fromNode.outputs } } }, { reemit: !options?.dry });
725
+ }
680
726
  async snapshotFull() {
681
727
  const def = undefined; // UI will supply def/positions on download for local
682
728
  const inputs = this.getInputs(this.runtime
@@ -999,7 +1045,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
999
1045
  // Trigger update so validation/UI refreshes using last known graph
1000
1046
  try {
1001
1047
  if (this.lastDef)
1002
- this.update(this.lastDef);
1048
+ await this.update(this.lastDef);
1003
1049
  }
1004
1050
  catch {
1005
1051
  console.error("Failed to update graph definition after registry changed");
@@ -1014,16 +1060,18 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1014
1060
  });
1015
1061
  }
1016
1062
  build(def) { }
1017
- update(def, options) {
1018
- // Remote: forward update; ignore errors (fire-and-forget)
1019
- this.ensureClient().then(async (client) => {
1020
- try {
1021
- await client.update(def, options);
1022
- this.emit("invalidate", { reason: "graph-updated" });
1023
- this.lastDef = def;
1024
- }
1025
- catch { }
1026
- });
1063
+ async update(def, options) {
1064
+ // Remote: forward update and await completion
1065
+ const client = await this.ensureClient();
1066
+ try {
1067
+ await client.update(def, options);
1068
+ this.emit("invalidate", { reason: "graph-updated" });
1069
+ this.lastDef = def;
1070
+ }
1071
+ catch (err) {
1072
+ // Log error but don't throw to maintain compatibility
1073
+ console.error("[RemoteGraphRunner] Error updating graph:", err);
1074
+ }
1027
1075
  }
1028
1076
  launch(def, opts) {
1029
1077
  super.launch(def, opts);
@@ -1144,13 +1192,53 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1144
1192
  const client = await this.ensureClient();
1145
1193
  await client.flush();
1146
1194
  }
1147
- triggerExternal(nodeId, event, options) {
1148
- this.ensureClient().then(async (client) => {
1149
- try {
1150
- await client.getEngine().triggerExternal(nodeId, event);
1195
+ setInputs(nodeId, inputs, options) {
1196
+ // Update staged inputs (for getInputs to work correctly)
1197
+ if (!this.stagedInputs[nodeId])
1198
+ this.stagedInputs[nodeId] = {};
1199
+ for (const [handle, value] of Object.entries(inputs)) {
1200
+ if (value === undefined) {
1201
+ delete this.stagedInputs[nodeId][handle];
1151
1202
  }
1152
- catch { }
1153
- });
1203
+ else {
1204
+ this.stagedInputs[nodeId][handle] = value;
1205
+ }
1206
+ }
1207
+ // If engine exists, call directly; otherwise ensure client (fire-and-forget)
1208
+ if (this.engine) {
1209
+ this.engine.setInputs(nodeId, inputs, options);
1210
+ }
1211
+ else {
1212
+ this.ensureClient()
1213
+ .then((client) => {
1214
+ client.getEngine().setInputs(nodeId, inputs, options);
1215
+ })
1216
+ .catch(() => {
1217
+ // Emit synthetic events if connection fails
1218
+ for (const [handle, value] of Object.entries(inputs)) {
1219
+ this.emit("value", { nodeId, handle, value, io: "input" });
1220
+ }
1221
+ });
1222
+ }
1223
+ }
1224
+ async copyOutputs(fromNodeId, toNodeId, options) {
1225
+ const client = await this.ensureClient();
1226
+ await client.copyOutputs(fromNodeId, toNodeId, options);
1227
+ }
1228
+ triggerExternal(nodeId, event, options) {
1229
+ // If engine exists, call directly; otherwise ensure client (fire-and-forget)
1230
+ if (this.engine) {
1231
+ this.engine.triggerExternal(nodeId, event, options);
1232
+ }
1233
+ else {
1234
+ this.ensureClient()
1235
+ .then((client) => {
1236
+ client.getEngine().triggerExternal(nodeId, event, options);
1237
+ })
1238
+ .catch(() => {
1239
+ // Silently fail if connection not available
1240
+ });
1241
+ }
1154
1242
  }
1155
1243
  async coerce(from, to, value) {
1156
1244
  const client = await this.ensureClient();
@@ -1208,18 +1296,19 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1208
1296
  }
1209
1297
  }
1210
1298
  }
1211
- setEnvironment(env, opts) {
1299
+ async setEnvironment(env, opts) {
1212
1300
  // Use client if available, otherwise ensure client and then set environment
1213
1301
  if (this.client) {
1214
- this.client.setEnvironment(env, opts).catch(() => { });
1302
+ await this.client.setEnvironment(env, opts);
1215
1303
  }
1216
1304
  else {
1217
- // If client not ready yet, ensure it and then set environment
1218
- this.ensureClient()
1219
- .then((client) => {
1220
- client.setEnvironment(env, opts).catch(() => { });
1221
- })
1222
- .catch(() => { });
1305
+ try {
1306
+ const client = await this.ensureClient();
1307
+ await client.setEnvironment(env, opts);
1308
+ }
1309
+ catch {
1310
+ // Silently fail if connection not available
1311
+ }
1223
1312
  }
1224
1313
  }
1225
1314
  getEnvironment() {
@@ -1280,7 +1369,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1280
1369
  }
1281
1370
  return out;
1282
1371
  }
1283
- dispose() {
1372
+ async dispose() {
1284
1373
  // Idempotent: allow multiple calls safely
1285
1374
  if (this.disposed)
1286
1375
  return;
@@ -1301,9 +1390,12 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1301
1390
  this.registryFetched = false; // Reset so registry is fetched again on reconnect
1302
1391
  this.registryFetching = false; // Reset fetching state
1303
1392
  if (clientToDispose) {
1304
- clientToDispose.dispose().catch((err) => {
1393
+ try {
1394
+ await clientToDispose.dispose();
1395
+ }
1396
+ catch (err) {
1305
1397
  console.warn("[RemoteGraphRunner] Error disposing client:", err);
1306
- });
1398
+ }
1307
1399
  }
1308
1400
  const disconnectedStatus = {
1309
1401
  state: "disconnected",
@@ -2626,6 +2718,36 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2626
2718
  }));
2627
2719
  }
2628
2720
  });
2721
+ const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
2722
+ if (!runner.isRunning())
2723
+ return;
2724
+ try {
2725
+ if (event.change?.type === "addNode") {
2726
+ const { nodeId, inputs, copyOutputsFrom } = event.change;
2727
+ if (event.dry) {
2728
+ await runner.update(event.def, { dry: true });
2729
+ if (inputs) {
2730
+ runner.setInputs(nodeId, inputs, { dry: true });
2731
+ }
2732
+ if (copyOutputsFrom) {
2733
+ runner.copyOutputs(copyOutputsFrom, nodeId, { dry: true });
2734
+ }
2735
+ }
2736
+ else {
2737
+ await runner.update(event.def, { dry: !!inputs });
2738
+ if (inputs) {
2739
+ runner.setInputs(nodeId, inputs, { dry: false });
2740
+ }
2741
+ }
2742
+ }
2743
+ else {
2744
+ await runner.update(event.def, { dry: event.dry });
2745
+ }
2746
+ }
2747
+ catch (err) {
2748
+ console.error("[WorkbenchContext] Error updating graph:", err);
2749
+ }
2750
+ });
2629
2751
  const offWbdSetValidation = wb.on("validationChanged", (r) => setValidation(r));
2630
2752
  const offWbSelectionChanged = wb.on("selectionChanged", (sel) => {
2631
2753
  setSelectedNodeId(sel.nodes?.[0]);
@@ -2633,13 +2755,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2633
2755
  });
2634
2756
  const offWbError = wb.on("error", add("workbench", "error"));
2635
2757
  // Registry updates: swap registry and refresh graph validation/UI
2636
- const offRunnerRegistry = runner.on("registry", (newReg) => {
2758
+ const offRunnerRegistry = runner.on("registry", async (newReg) => {
2637
2759
  try {
2638
2760
  setRegistry(newReg);
2639
2761
  wb.setRegistry(newReg);
2640
2762
  // Trigger a graph update so the UI revalidates with new types/enums/nodes
2641
2763
  try {
2642
- runner.update(wb.export());
2764
+ await runner.update(wb.export());
2643
2765
  }
2644
2766
  catch {
2645
2767
  console.error("Failed to update graph definition after registry changed");
@@ -2670,6 +2792,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2670
2792
  offWbValidationChanged();
2671
2793
  offWbError();
2672
2794
  offWbAddNode();
2795
+ offWbGraphChangedForUpdate();
2673
2796
  offWbdSetValidation();
2674
2797
  offWbSelectionChanged();
2675
2798
  offRunnerRegistry();
@@ -2687,16 +2810,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2687
2810
  const stop = React.useCallback(() => runner.stop(), [runner]);
2688
2811
  const step = React.useCallback(() => runner.step(), [runner]);
2689
2812
  const flush = React.useCallback(() => runner.flush(), [runner]);
2690
- // Push incremental updates into running engine without full reload
2691
- const isGraphRunning = isRunning();
2692
- React.useEffect(() => {
2693
- if (isGraphRunning) {
2694
- try {
2695
- runner.update(def);
2696
- }
2697
- catch { }
2698
- }
2699
- }, [runner, isGraphRunning, def, graphTick]);
2700
2813
  const validationByNode = React.useMemo(() => {
2701
2814
  const inputs = {};
2702
2815
  const outputs = {};
@@ -3448,7 +3561,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
3448
3561
  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", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3449
3562
  e.preventDefault();
3450
3563
  e.stopPropagation();
3451
- }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicate, children: "Duplicate" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [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))), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" })] })), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" })] }));
3564
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicate, children: "Duplicate" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), bakeableOutputs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [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))), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" })] })), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" })] }));
3452
3565
  }
3453
3566
 
3454
3567
  function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap, outputTypesMap, onClose) {
@@ -3481,26 +3594,20 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3481
3594
  const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3482
3595
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3483
3596
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3484
- const newId = wb.addNode({
3597
+ wb.addNode({
3485
3598
  typeId: singleTarget.nodeTypeId,
3486
3599
  position: { x: pos.x + 180, y: pos.y },
3487
- });
3488
- runner.update(wb.export());
3489
- await runner.whenIdle();
3490
- runner.setInputs(newId, { [singleTarget.inputHandle]: coerced });
3600
+ }, { inputs: { [singleTarget.inputHandle]: coerced } });
3491
3601
  return;
3492
3602
  }
3493
3603
  if (isArray && arrTarget) {
3494
3604
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3495
3605
  const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3496
3606
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3497
- const newId = wb.addNode({
3607
+ wb.addNode({
3498
3608
  typeId: arrTarget.nodeTypeId,
3499
3609
  position: { x: pos.x + 180, y: pos.y },
3500
- });
3501
- runner.update(wb.export());
3502
- await runner.whenIdle();
3503
- runner.setInputs(newId, { [arrTarget.inputHandle]: coerced });
3610
+ }, { inputs: { [arrTarget.inputHandle]: coerced } });
3504
3611
  return;
3505
3612
  }
3506
3613
  if (isArray && elemTarget) {
@@ -3512,24 +3619,13 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3512
3619
  const COLS = 4;
3513
3620
  const DX = 180;
3514
3621
  const DY = 160;
3515
- const nodeIds = [];
3516
3622
  for (let idx = 0; idx < coercedItems.length; idx++) {
3517
3623
  const col = idx % COLS;
3518
3624
  const row = Math.floor(idx / COLS);
3519
- const newId = wb.addNode({
3625
+ wb.addNode({
3520
3626
  typeId: elemTarget.nodeTypeId,
3521
3627
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3522
- });
3523
- nodeIds.push(newId);
3524
- }
3525
- if (nodeIds.length > 0) {
3526
- runner.update(wb.export());
3527
- await runner.whenIdle();
3528
- for (let idx = 0; idx < coercedItems.length; idx++) {
3529
- runner.setInputs(nodeIds[idx], {
3530
- [elemTarget.inputHandle]: coercedItems[idx],
3531
- });
3532
- }
3628
+ }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3533
3629
  }
3534
3630
  return;
3535
3631
  }
@@ -3547,14 +3643,52 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3547
3643
  if (!n)
3548
3644
  return onClose();
3549
3645
  const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
3550
- const newId = wb.addNode({
3646
+ const inboundHandles = new Set(def.edges
3647
+ .filter((e) => e.target.nodeId === nodeId)
3648
+ .map((e) => e.target.handle));
3649
+ const allInputs = runner.getInputs(def)[nodeId] || {};
3650
+ const inputsWithoutBindings = Object.fromEntries(Object.entries(allInputs).filter(([handle]) => !inboundHandles.has(handle)));
3651
+ wb.addNode({
3652
+ typeId: n.typeId,
3653
+ params: n.params,
3654
+ position: { x: pos.x + 24, y: pos.y + 24 },
3655
+ resolvedHandles: n.resolvedHandles,
3656
+ }, {
3657
+ inputs: inputsWithoutBindings,
3658
+ copyOutputsFrom: nodeId,
3659
+ dry: true,
3660
+ });
3661
+ onClose();
3662
+ },
3663
+ onDuplicateWithEdges: async () => {
3664
+ const def = wb.export();
3665
+ const n = def.nodes.find((n) => n.nodeId === nodeId);
3666
+ if (!n)
3667
+ return onClose();
3668
+ const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
3669
+ // Get inputs without bindings (literal values only)
3670
+ const inputs = runner.getInputs(def)[nodeId] || {};
3671
+ // Add the duplicated node
3672
+ const newNodeId = wb.addNode({
3551
3673
  typeId: n.typeId,
3552
3674
  params: n.params,
3553
3675
  position: { x: pos.x + 24, y: pos.y + 24 },
3554
3676
  resolvedHandles: n.resolvedHandles,
3677
+ }, {
3678
+ inputs,
3679
+ copyOutputsFrom: nodeId,
3680
+ dry: true,
3555
3681
  });
3556
- await runner.whenIdle();
3557
- runner.setInputs(newId, { ...runner.getInputs(def)[nodeId] });
3682
+ // Find all incoming edges (edges where target is the original node)
3683
+ const incomingEdges = def.edges.filter((e) => e.target.nodeId === nodeId);
3684
+ // Duplicate each incoming edge to point to the new node
3685
+ for (const edge of incomingEdges) {
3686
+ wb.connect({
3687
+ source: edge.source, // Keep the same source
3688
+ target: { nodeId: newNodeId, handle: edge.target.handle }, // Point to new node
3689
+ typeId: edge.typeId,
3690
+ }, { dry: true });
3691
+ }
3558
3692
  onClose();
3559
3693
  },
3560
3694
  onRunPull: async () => {
@@ -3873,17 +4007,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3873
4007
  setNodeMenuOpen(false);
3874
4008
  }
3875
4009
  };
3876
- const addNodeAt = React.useCallback(async (typeId, opts) => {
3877
- const nodeId = wb.addNode({
3878
- typeId,
3879
- position: opts.position,
3880
- });
3881
- if (opts.inputs) {
3882
- runner.update(wb.export());
3883
- await runner.whenIdle();
3884
- runner.setInputs(nodeId, opts.inputs);
3885
- }
3886
- }, [wb, runner]);
4010
+ const addNodeAt = React.useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs }), [wb]);
3887
4011
  const onCloseMenu = React.useCallback(() => {
3888
4012
  setMenuOpen(false);
3889
4013
  }, []);