@bian-womp/spark-workbench 0.3.83 → 0.3.85

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