@bian-womp/spark-workbench 0.3.84 → 0.3.86

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.
package/lib/cjs/index.cjs CHANGED
@@ -139,6 +139,8 @@ class InMemoryWorkbench extends AbstractWorkbench {
139
139
  super(args);
140
140
  this._def = { nodes: [], edges: [] };
141
141
  this.listeners = new Map();
142
+ this._batchChanges = null;
143
+ this._batchDepth = 0;
142
144
  this.positions = {};
143
145
  this.sizes = {};
144
146
  this.selection = {
@@ -410,16 +412,24 @@ class InMemoryWorkbench extends AbstractWorkbench {
410
412
  */
411
413
  deleteSelection(options) {
412
414
  const selection = this.getSelection();
413
- // Delete all selected nodes (this will also remove connected edges)
414
- for (const nodeId of selection.nodes) {
415
- this.removeNode(nodeId);
415
+ this.beginBatch();
416
+ let completed = false;
417
+ try {
418
+ // Delete all selected nodes (this will also remove connected edges)
419
+ for (const nodeId of selection.nodes) {
420
+ this.removeNode(nodeId);
421
+ }
422
+ // Delete remaining selected edges (edges not connected to deleted nodes)
423
+ for (const edgeId of selection.edges) {
424
+ this.disconnect(edgeId);
425
+ }
426
+ // Clear selection
427
+ this.setSelectionInternal({ nodes: [], edges: [] }, options);
428
+ completed = true;
416
429
  }
417
- // Delete remaining selected edges (edges not connected to deleted nodes)
418
- for (const edgeId of selection.edges) {
419
- this.disconnect(edgeId);
430
+ finally {
431
+ this.endBatch(completed ? options : { ...options, commit: false });
420
432
  }
421
- // Clear selection
422
- this.setSelectionInternal({ nodes: [], edges: [] }, options);
423
433
  }
424
434
  setViewport(viewport) {
425
435
  if (lod.isEqual(this.viewport, viewport))
@@ -519,11 +529,69 @@ class InMemoryWorkbench extends AbstractWorkbench {
519
529
  return () => set.delete(handler);
520
530
  }
521
531
  emit(event, payload) {
532
+ if (this._batchDepth > 0 && this._batchChanges !== null) {
533
+ if (event === "graphChanged") {
534
+ const d = payload;
535
+ if (d.change && d.change.type !== "load" && d.change.type !== "batch") {
536
+ this._batchChanges.push(d.change);
537
+ }
538
+ return;
539
+ }
540
+ if (event === "graphUiChanged") {
541
+ const d = payload;
542
+ if (d.commit) {
543
+ const stripped = { ...d, commit: undefined };
544
+ const set = this.listeners.get(event);
545
+ if (set)
546
+ for (const h of Array.from(set))
547
+ h(stripped);
548
+ return;
549
+ }
550
+ }
551
+ if (event === "validationChanged") {
552
+ return;
553
+ }
554
+ }
522
555
  const set = this.listeners.get(event);
523
556
  if (set)
524
557
  for (const h of Array.from(set))
525
558
  h(payload);
526
559
  }
560
+ beginBatch() {
561
+ if (this._batchDepth === 0) {
562
+ this._batchChanges = [];
563
+ }
564
+ this._batchDepth++;
565
+ }
566
+ endBatch(options) {
567
+ if (this._batchDepth <= 0)
568
+ return;
569
+ this._batchDepth--;
570
+ if (this._batchDepth > 0)
571
+ return;
572
+ const changes = this._batchChanges;
573
+ this._batchChanges = null;
574
+ if (!changes || changes.length === 0)
575
+ return;
576
+ const set = this.listeners.get("graphChanged");
577
+ if (set) {
578
+ const payload = {
579
+ def: this._def,
580
+ change: { type: "batch", changes },
581
+ ...options,
582
+ };
583
+ for (const h of Array.from(set))
584
+ h(payload);
585
+ }
586
+ this.refreshValidation();
587
+ }
588
+ getInboundHandleIds(nodeId, shouldExcludeEdge) {
589
+ const inboundEdges = this.def.edges.filter((e) => e.target.nodeId === nodeId);
590
+ return new Set(inboundEdges.filter((e) => shouldExcludeEdge(e)).map((e) => e.target.handle));
591
+ }
592
+ excludeHandlesFromInputs(allNodeInputs, excludedHandles) {
593
+ return Object.fromEntries(Object.entries(allNodeInputs).filter(([handle]) => !excludedHandles.has(handle)));
594
+ }
527
595
  /**
528
596
  * Copy selected nodes and their internal edges.
529
597
  * Returns data in a format suitable for pasting.
@@ -569,18 +637,12 @@ class InMemoryWorkbench extends AbstractWorkbench {
569
637
  const pos = positions[node.nodeId];
570
638
  const size = sizes[node.nodeId];
571
639
  const customNodeData = this.getCustomNodeData(node.nodeId);
572
- // Get all inbound edges for this node
573
- const inboundEdges = this.def.edges.filter((e) => e.target.nodeId === node.nodeId);
574
- // Build set of handles that have inbound edges
575
- // But only exclude handles whose edges are NOT selected
576
- const inboundHandlesToExclude = new Set(inboundEdges
577
- .filter((e) => !selectedEdgeSet.has(e.id)) // Only exclude if edge is not selected
578
- .map((e) => e.target.handle));
640
+ const inboundHandlesToExclude = this.getInboundHandleIds(node.nodeId, (e) => !selectedEdgeSet.has(e.id));
579
641
  const allNodeInputs = allInputs[node.nodeId] || {};
580
642
  // Include inputs that either:
581
643
  // 1. Don't have inbound edges (literal values)
582
644
  // 2. Have inbound edges that ARE selected (preserve the value from the edge)
583
- const inputsToCopy = Object.fromEntries(Object.entries(allNodeInputs).filter(([handle]) => !inboundHandlesToExclude.has(handle)));
645
+ const inputsToCopy = this.excludeHandlesFromInputs(allNodeInputs, inboundHandlesToExclude);
584
646
  return {
585
647
  typeId: node.typeId,
586
648
  params: node.params,
@@ -621,21 +683,21 @@ class InMemoryWorkbench extends AbstractWorkbench {
621
683
  * Returns the ID of the last created node (or undefined if none created).
622
684
  */
623
685
  async bake(registry, runner, outputValue, outputTypeId, nodePosition, options) {
686
+ if (!outputTypeId || outputValue === undefined)
687
+ return undefined;
688
+ const bakeOpts = options || { commit: true, reason: "bake" };
689
+ this.beginBatch();
690
+ let completed = false;
624
691
  try {
625
- if (!outputTypeId || outputValue === undefined)
626
- return undefined;
627
692
  const unwrap = (v) => (sparkGraph.isTyped(v) ? sparkGraph.unwrapValue(v) : v);
628
693
  const coerceIfNeeded = async (fromType, toTypes, value) => {
629
694
  if (!toTypes)
630
695
  return value;
631
696
  const typesArray = Array.isArray(toTypes) ? toTypes : [toTypes];
632
- // If output type matches any input type exactly, no coercion needed
633
697
  if (typesArray.includes(fromType))
634
698
  return value;
635
- // If no coercion function available, return as-is
636
699
  if (!runner?.coerce)
637
700
  return value;
638
- // Try coercing to each type until one succeeds
639
701
  for (const toType of typesArray) {
640
702
  try {
641
703
  return await runner.coerce(fromType, toType, value);
@@ -644,7 +706,6 @@ class InMemoryWorkbench extends AbstractWorkbench {
644
706
  // Continue to next type
645
707
  }
646
708
  }
647
- // If all coercion attempts failed, return value as-is
648
709
  return value;
649
710
  };
650
711
  const pos = nodePosition;
@@ -699,13 +760,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
699
760
  }
700
761
  }
701
762
  if (newNodeId) {
702
- this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, options || { commit: true, reason: "bake" });
763
+ this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, bakeOpts);
703
764
  }
765
+ completed = true;
704
766
  return newNodeId;
705
767
  }
706
768
  catch {
707
769
  return undefined;
708
770
  }
771
+ finally {
772
+ this.endBatch(completed ? bakeOpts : { ...bakeOpts, commit: false });
773
+ }
709
774
  }
710
775
  /**
711
776
  * Duplicate all selected nodes.
@@ -717,88 +782,85 @@ class InMemoryWorkbench extends AbstractWorkbench {
717
782
  const selection = this.getSelection();
718
783
  if (selection.nodes.length === 0)
719
784
  return [];
720
- const positions = this.getPositions();
721
- const sizes = this.getSizes();
722
- const newNodes = [];
723
- const nodeIdMap = new Map(); // Map from original nodeId to new nodeId
724
- const processedNodes = new Set(); // Track which nodes have been duplicated
725
- const selectedNodeSet = new Set(selection.nodes);
726
- const allInputs = runner.getInputs(this.def) || {};
727
- // Build a map of incoming edges for each selected node
728
- const incomingEdgesByNode = new Map();
729
- for (const nodeId of selection.nodes) {
730
- const incomingEdges = this.def.edges.filter((e) => e.target.nodeId === nodeId);
731
- incomingEdgesByNode.set(nodeId, incomingEdges);
732
- }
733
- // Helper function to check if a node is ready to be processed
734
- // (all its dependencies from selected nodes have been processed)
735
- const isNodeReady = (nodeId) => {
736
- const incomingEdges = incomingEdgesByNode.get(nodeId) || [];
737
- for (const edge of incomingEdges) {
738
- // If the source is a selected node, it must have been processed
739
- if (selectedNodeSet.has(edge.source.nodeId)) {
740
- if (!processedNodes.has(edge.source.nodeId)) {
741
- return false;
785
+ const reason = options?.reason || "duplicate-selection";
786
+ const batchOptions = { commit: options?.commit ?? true, reason, dry: true };
787
+ this.beginBatch();
788
+ let completed = false;
789
+ try {
790
+ const positions = this.getPositions();
791
+ const sizes = this.getSizes();
792
+ const newNodes = [];
793
+ const nodeIdMap = new Map();
794
+ const processedNodes = new Set();
795
+ const selectedNodeSet = new Set(selection.nodes);
796
+ const allInputs = runner.getInputs(this.def) || {};
797
+ const incomingEdgesByNode = new Map();
798
+ for (const nodeId of selection.nodes) {
799
+ const incomingEdges = this.def.edges.filter((e) => e.target.nodeId === nodeId);
800
+ incomingEdgesByNode.set(nodeId, incomingEdges);
801
+ }
802
+ const isNodeReady = (nodeId) => {
803
+ const incomingEdges = incomingEdgesByNode.get(nodeId) || [];
804
+ for (const edge of incomingEdges) {
805
+ if (selectedNodeSet.has(edge.source.nodeId)) {
806
+ if (!processedNodes.has(edge.source.nodeId)) {
807
+ return false;
808
+ }
742
809
  }
743
810
  }
744
- }
745
- return true;
746
- };
747
- // Process nodes in topological order
748
- let remainingNodes = new Set(selection.nodes);
749
- while (remainingNodes.size > 0) {
750
- // Find nodes that are ready to be processed (no unprocessed dependencies)
751
- const readyNodes = Array.from(remainingNodes).filter((nodeId) => isNodeReady(nodeId));
752
- if (readyNodes.length === 0) {
753
- // If no nodes are ready, there might be a cycle. Process remaining nodes anyway.
754
- // This shouldn't happen in a DAG, but handle it gracefully.
755
- readyNodes.push(...remainingNodes);
756
- }
757
- // Process each ready node
758
- for (const nodeId of readyNodes) {
759
- const n = this.def.nodes.find((n) => n.nodeId === nodeId);
760
- if (!n) {
761
- remainingNodes.delete(nodeId);
762
- continue;
811
+ return true;
812
+ };
813
+ let remainingNodes = new Set(selection.nodes);
814
+ while (remainingNodes.size > 0) {
815
+ const readyNodes = Array.from(remainingNodes).filter((nodeId) => isNodeReady(nodeId));
816
+ if (readyNodes.length === 0) {
817
+ readyNodes.push(...remainingNodes);
763
818
  }
764
- const pos = positions[nodeId];
765
- const size = sizes[nodeId];
766
- // Get all inputs (including those with bindings, since edges will be duplicated)
767
- const inputs = allInputs[nodeId] || {};
768
- // Create the duplicated node
769
- const newNodeId = this.addNode({
770
- typeId: n.typeId,
771
- params: n.params,
772
- resolvedHandles: n.resolvedHandles,
773
- }, {
774
- inputs,
775
- position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
776
- size,
777
- copyOutputsFrom: nodeId,
778
- dry: true,
779
- });
780
- newNodes.push(newNodeId);
781
- nodeIdMap.set(nodeId, newNodeId);
782
- processedNodes.add(nodeId);
783
- // Connect incoming edges for this node
784
- const incomingEdges = incomingEdgesByNode.get(nodeId) || [];
785
- for (const edge of incomingEdges) {
786
- // Determine the source node: use duplicated node if it was duplicated, otherwise use original
787
- const sourceNodeId = nodeIdMap.get(edge.source.nodeId) || edge.source.nodeId;
788
- this.connect({
789
- source: { nodeId: sourceNodeId, handle: edge.source.handle },
790
- target: { nodeId: newNodeId, handle: edge.target.handle },
791
- typeId: edge.typeId,
792
- }, { dry: true });
819
+ for (const nodeId of readyNodes) {
820
+ const n = this.def.nodes.find((n) => n.nodeId === nodeId);
821
+ if (!n) {
822
+ remainingNodes.delete(nodeId);
823
+ continue;
824
+ }
825
+ const pos = positions[nodeId];
826
+ const size = sizes[nodeId];
827
+ const inputs = allInputs[nodeId] || {};
828
+ const newNodeId = this.addNode({
829
+ typeId: n.typeId,
830
+ params: n.params,
831
+ resolvedHandles: n.resolvedHandles,
832
+ }, {
833
+ inputs,
834
+ position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
835
+ size,
836
+ copyOutputsFrom: nodeId,
837
+ dry: true,
838
+ reason,
839
+ });
840
+ newNodes.push(newNodeId);
841
+ nodeIdMap.set(nodeId, newNodeId);
842
+ processedNodes.add(nodeId);
843
+ const incomingEdges = incomingEdgesByNode.get(nodeId) || [];
844
+ for (const edge of incomingEdges) {
845
+ const sourceNodeId = nodeIdMap.get(edge.source.nodeId) || edge.source.nodeId;
846
+ this.connect({
847
+ source: { nodeId: sourceNodeId, handle: edge.source.handle },
848
+ target: { nodeId: newNodeId, handle: edge.target.handle },
849
+ typeId: edge.typeId,
850
+ }, { dry: true, reason });
851
+ }
852
+ remainingNodes.delete(nodeId);
793
853
  }
794
- remainingNodes.delete(nodeId);
795
854
  }
855
+ if (newNodes.length > 0) {
856
+ this.setSelectionInternal({ nodes: newNodes, edges: [] }, options || { commit: true, reason });
857
+ }
858
+ completed = true;
859
+ return newNodes;
796
860
  }
797
- // Select all newly duplicated nodes
798
- if (newNodes.length > 0) {
799
- this.setSelectionInternal({ nodes: newNodes, edges: [] }, options || { commit: true, reason: "duplicate-selection" });
861
+ finally {
862
+ this.endBatch(completed ? batchOptions : { ...batchOptions, commit: false });
800
863
  }
801
- return newNodes;
802
864
  }
803
865
  /**
804
866
  * Duplicate a node and all its incoming edges.
@@ -810,37 +872,43 @@ class InMemoryWorkbench extends AbstractWorkbench {
810
872
  const n = this.def.nodes.find((n) => n.nodeId === nodeId);
811
873
  if (!n)
812
874
  return undefined;
813
- const pos = this.getPositions()[nodeId];
814
- const size = this.getSizes()[nodeId];
815
- // Get all inputs (including those with bindings, since edges will be duplicated)
816
- const inputs = runner.getInputs(this.def)[nodeId] || {};
817
- // Add the duplicated node
818
- const newNodeId = this.addNode({
819
- typeId: n.typeId,
820
- params: n.params,
821
- resolvedHandles: n.resolvedHandles,
822
- }, {
823
- inputs,
824
- position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
825
- size,
826
- copyOutputsFrom: nodeId,
827
- dry: true,
828
- });
829
- // Find all incoming edges (edges where target is the original node)
830
- const incomingEdges = this.def.edges.filter((e) => e.target.nodeId === nodeId);
831
- // Duplicate each incoming edge to point to the new node
832
- for (const edge of incomingEdges) {
833
- this.connect({
834
- source: edge.source, // Keep the same source
835
- target: { nodeId: newNodeId, handle: edge.target.handle }, // Point to new node
836
- typeId: edge.typeId,
837
- }, { dry: true });
838
- }
839
- // Select the newly duplicated node
840
- if (newNodeId) {
841
- this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, options || { commit: true, reason: "duplicate-node" });
842
- }
843
- return newNodeId;
875
+ const reason = options?.reason || "duplicate-node";
876
+ const batchOptions = { commit: options?.commit ?? true, reason, dry: true };
877
+ this.beginBatch();
878
+ let completed = false;
879
+ try {
880
+ const pos = this.getPositions()[nodeId];
881
+ const size = this.getSizes()[nodeId];
882
+ const inputs = runner.getInputs(this.def)[nodeId] || {};
883
+ const newNodeId = this.addNode({
884
+ typeId: n.typeId,
885
+ params: n.params,
886
+ resolvedHandles: n.resolvedHandles,
887
+ }, {
888
+ inputs,
889
+ position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
890
+ size,
891
+ copyOutputsFrom: nodeId,
892
+ dry: true,
893
+ reason,
894
+ });
895
+ const incomingEdges = this.def.edges.filter((e) => e.target.nodeId === nodeId);
896
+ for (const edge of incomingEdges) {
897
+ this.connect({
898
+ source: edge.source,
899
+ target: { nodeId: newNodeId, handle: edge.target.handle },
900
+ typeId: edge.typeId,
901
+ }, { dry: true, reason });
902
+ }
903
+ if (newNodeId) {
904
+ this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, options || { commit: true, reason });
905
+ }
906
+ completed = true;
907
+ return newNodeId;
908
+ }
909
+ finally {
910
+ this.endBatch(completed ? batchOptions : { ...batchOptions, commit: false });
911
+ }
844
912
  }
845
913
  /**
846
914
  * Paste copied graph data at the specified center position.
@@ -850,54 +918,62 @@ class InMemoryWorkbench extends AbstractWorkbench {
850
918
  pasteCopiedData(data, center, options) {
851
919
  const nodeIdMap = new Map();
852
920
  const edgeIds = [];
853
- // Add nodes
854
- for (const nodeData of data.nodes) {
855
- const newNodeId = this.addNode({
856
- typeId: nodeData.typeId,
857
- params: nodeData.params,
858
- resolvedHandles: nodeData.resolvedHandles,
859
- }, {
860
- inputs: nodeData.inputs,
861
- position: nodeData.position
862
- ? {
863
- x: nodeData.position.x + center.x,
864
- y: nodeData.position.y + center.y,
865
- }
866
- : undefined,
867
- size: nodeData.size,
868
- copyOutputsFrom: nodeData.originalNodeId,
869
- dry: true,
870
- });
871
- nodeIdMap.set(nodeData.originalNodeId, newNodeId);
872
- if (nodeData.customData !== undefined) {
873
- this.setCustomNodeData(newNodeId, lod.cloneDeep(nodeData.customData), {
874
- commit: false,
875
- reason: options?.reason,
921
+ const reason = options?.reason || "paste";
922
+ const batchOptions = { commit: options?.commit ?? true, reason, dry: true };
923
+ this.beginBatch();
924
+ let completed = false;
925
+ try {
926
+ for (const nodeData of data.nodes) {
927
+ const newNodeId = this.addNode({
928
+ typeId: nodeData.typeId,
929
+ params: nodeData.params,
930
+ resolvedHandles: nodeData.resolvedHandles,
931
+ }, {
932
+ inputs: nodeData.inputs,
933
+ position: nodeData.position
934
+ ? {
935
+ x: nodeData.position.x + center.x,
936
+ y: nodeData.position.y + center.y,
937
+ }
938
+ : undefined,
939
+ size: nodeData.size,
940
+ copyOutputsFrom: nodeData.originalNodeId,
941
+ dry: true,
942
+ reason,
876
943
  });
944
+ nodeIdMap.set(nodeData.originalNodeId, newNodeId);
945
+ if (nodeData.customData !== undefined) {
946
+ this.setCustomNodeData(newNodeId, lod.cloneDeep(nodeData.customData), {
947
+ commit: false,
948
+ reason: options?.reason,
949
+ });
950
+ }
877
951
  }
878
- }
879
- // Add edges
880
- for (const edgeData of data.edges) {
881
- const newSourceNodeId = nodeIdMap.get(edgeData.sourceNodeId);
882
- const newTargetNodeId = nodeIdMap.get(edgeData.targetNodeId);
883
- if (newSourceNodeId && newTargetNodeId) {
884
- const edgeId = this.connect({
885
- source: {
886
- nodeId: newSourceNodeId,
887
- handle: edgeData.sourceHandle,
888
- },
889
- target: {
890
- nodeId: newTargetNodeId,
891
- handle: edgeData.targetHandle,
892
- },
893
- typeId: edgeData.typeId,
894
- }, { dry: true });
895
- edgeIds.push(edgeId);
952
+ for (const edgeData of data.edges) {
953
+ const newSourceNodeId = nodeIdMap.get(edgeData.sourceNodeId);
954
+ const newTargetNodeId = nodeIdMap.get(edgeData.targetNodeId);
955
+ if (newSourceNodeId && newTargetNodeId) {
956
+ const edgeId = this.connect({
957
+ source: {
958
+ nodeId: newSourceNodeId,
959
+ handle: edgeData.sourceHandle,
960
+ },
961
+ target: {
962
+ nodeId: newTargetNodeId,
963
+ handle: edgeData.targetHandle,
964
+ },
965
+ typeId: edgeData.typeId,
966
+ }, { dry: true, reason });
967
+ edgeIds.push(edgeId);
968
+ }
896
969
  }
970
+ this.setSelectionInternal({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
971
+ completed = true;
972
+ return { nodeIdMap, edgeIds };
973
+ }
974
+ finally {
975
+ this.endBatch(completed ? batchOptions : { ...batchOptions, commit: false });
897
976
  }
898
- // Select the newly pasted nodes
899
- this.setSelectionInternal({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
900
- return { nodeIdMap, edgeIds };
901
977
  }
902
978
  /**
903
979
  * Get the currently copied graph data.
@@ -4382,17 +4458,62 @@ function WorkbenchProvider({ wb, runner, overrides, uiVersion, children, }) {
4382
4458
  setRegistryVersion((v) => v + 1);
4383
4459
  });
4384
4460
  const offWbGraphChanged = wb.on("graphChanged", (event) => {
4385
- // Clear validation errors for removed nodes
4386
4461
  if (event.change?.type === "removeNode") {
4387
4462
  const removedNodeId = event.change.nodeId;
4388
4463
  setInputValidationErrors((prev) => prev.filter((err) => err.nodeId !== removedNodeId));
4389
4464
  }
4465
+ else if (event.change?.type === "batch") {
4466
+ const removedIds = event.change.changes
4467
+ .filter((c) => c.type === "removeNode")
4468
+ .map((c) => c.nodeId);
4469
+ if (removedIds.length > 0) {
4470
+ setInputValidationErrors((prev) => prev.filter((err) => !removedIds.includes(err.nodeId)));
4471
+ }
4472
+ }
4390
4473
  return add("workbench", "graphChanged")(event);
4391
4474
  });
4392
4475
  const offWbGraphUiChangedForLog = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
4393
4476
  const offWbValidationChanged = wb.on("validationChanged", add("workbench", "validationChanged"));
4394
4477
  // Ensure newly added nodes start as invalidated until first evaluation
4395
4478
  const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
4479
+ // Handle batched compound operations (duplicate, paste, delete, bake)
4480
+ if (event.change?.type === "batch") {
4481
+ const { changes } = event.change;
4482
+ const isDry = !!event.dry;
4483
+ const batchReason = event.reason ?? "batch";
4484
+ try {
4485
+ if (runner.isRunning()) {
4486
+ const hasNodeOps = changes.some((c) => c.type === "addNode" && (c.inputs || c.copyOutputsFrom));
4487
+ await runner.update(event.def, { dry: isDry || hasNodeOps });
4488
+ for (const change of changes) {
4489
+ if (change.type === "addNode") {
4490
+ if (change.inputs) {
4491
+ await runner.setInputs(change.nodeId, change.inputs, { dry: isDry });
4492
+ }
4493
+ if (change.copyOutputsFrom) {
4494
+ await runner.copyOutputs(change.copyOutputsFrom, change.nodeId, { dry: isDry });
4495
+ }
4496
+ }
4497
+ if (change.type === "setInputs") {
4498
+ await runner.setInputs(change.nodeId, change.inputs, { dry: isDry });
4499
+ }
4500
+ }
4501
+ }
4502
+ if (event.commit) {
4503
+ await saveUiRuntimeMetadata(wb, runner);
4504
+ const history = await runner.commit(batchReason).catch((err) => {
4505
+ console.error("[WorkbenchContext] Error committing batch:", err);
4506
+ return undefined;
4507
+ });
4508
+ if (history)
4509
+ wb.setHistory(history);
4510
+ }
4511
+ }
4512
+ catch (err) {
4513
+ console.error("[WorkbenchContext] Error processing batch:", err);
4514
+ }
4515
+ return;
4516
+ }
4396
4517
  // Build detailed reason from change type
4397
4518
  let reason = "graph-changed";
4398
4519
  if (event.change) {