@bian-womp/spark-workbench 0.2.68 → 0.2.70

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 +390 -88
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +27 -7
  4. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/contracts.d.ts +5 -0
  6. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/DefaultContextMenu.d.ts +1 -1
  8. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/NodeContextMenu.d.ts +1 -1
  10. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/SelectionContextMenu.d.ts +1 -1
  12. package/lib/cjs/src/misc/SelectionContextMenu.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts +22 -0
  15. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  16. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts +1 -1
  17. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  18. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +11 -22
  19. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  20. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  21. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  22. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +6 -1
  23. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  24. package/lib/cjs/src/runtime/IGraphRunner.d.ts +6 -1
  25. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  26. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +6 -1
  27. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  28. package/lib/esm/index.js +390 -88
  29. package/lib/esm/index.js.map +1 -1
  30. package/lib/esm/src/core/InMemoryWorkbench.d.ts +27 -7
  31. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  32. package/lib/esm/src/core/contracts.d.ts +5 -0
  33. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  34. package/lib/esm/src/misc/DefaultContextMenu.d.ts +1 -1
  35. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +1 -1
  36. package/lib/esm/src/misc/NodeContextMenu.d.ts +1 -1
  37. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  38. package/lib/esm/src/misc/SelectionContextMenu.d.ts +1 -1
  39. package/lib/esm/src/misc/SelectionContextMenu.d.ts.map +1 -1
  40. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  41. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts +22 -0
  42. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +1 -1
  43. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts +1 -1
  44. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +1 -1
  45. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +11 -22
  46. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  47. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  48. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  49. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +6 -1
  50. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  51. package/lib/esm/src/runtime/IGraphRunner.d.ts +6 -1
  52. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  53. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +6 -1
  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,5 @@
1
1
  import { generateId, GraphBuilder, createEngine, StepEngine, PullEngine, BatchedEngine, isTypedOutput, getTypedOutputValue, getTypedOutputTypeId, isInputPrivate, getInputTypeId, createSimpleGraphRegistry, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
2
+ import lod from 'lodash';
2
3
  import { RuntimeApiClient } from '@bian-womp/spark-remote';
3
4
  import { Position, Handle, useUpdateNodeInternals, useReactFlow, ReactFlowProvider, ReactFlow, Background, BackgroundVariant, MiniMap, Controls } from '@xyflow/react';
4
5
  import React, { useCallback, useState, useRef, useEffect, useMemo, createContext, useContext, useImperativeHandle } from 'react';
@@ -208,18 +209,19 @@ class InMemoryWorkbench extends AbstractWorkbench {
208
209
  inputs: options?.inputs,
209
210
  copyOutputsFrom: options?.copyOutputsFrom,
210
211
  },
211
- dry: options?.dry,
212
+ ...lod.pick(options, ["dry", "commit", "reason"]),
212
213
  });
213
214
  this.refreshValidation();
214
215
  return id;
215
216
  }
216
- removeNode(nodeId) {
217
+ removeNode(nodeId, options) {
217
218
  this.def.nodes = this.def.nodes.filter((n) => n.nodeId !== nodeId);
218
219
  this.def.edges = this.def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
219
220
  delete this.positions[nodeId];
220
221
  this.emit("graphChanged", {
221
222
  def: this.def,
222
223
  change: { type: "removeNode", nodeId },
224
+ ...options,
223
225
  });
224
226
  this.refreshValidation();
225
227
  }
@@ -234,16 +236,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
234
236
  this.emit("graphChanged", {
235
237
  def: this.def,
236
238
  change: { type: "connect", edgeId: id },
237
- dry: options?.dry,
239
+ ...options,
238
240
  });
239
241
  this.refreshValidation();
240
242
  return id;
241
243
  }
242
- disconnect(edgeId) {
244
+ disconnect(edgeId, options) {
243
245
  this.def.edges = this.def.edges.filter((e) => e.id !== edgeId);
244
246
  this.emit("graphChanged", {
245
247
  def: this.def,
246
248
  change: { type: "disconnect", edgeId },
249
+ ...options,
247
250
  });
248
251
  this.emit("validationChanged", this.validate());
249
252
  }
@@ -272,32 +275,32 @@ class InMemoryWorkbench extends AbstractWorkbench {
272
275
  });
273
276
  }
274
277
  // Position and selection APIs for React Flow bridge
275
- setPosition(nodeId, pos, opts) {
278
+ setPosition(nodeId, pos, options) {
276
279
  this.positions[nodeId] = pos;
277
280
  this.emit("graphUiChanged", {
278
281
  def: this.def,
279
282
  change: { type: "moveNode", nodeId, pos },
280
- commit: !!opts?.commit === true,
283
+ ...options,
281
284
  });
282
285
  }
283
- setPositions(map, opts) {
286
+ setPositions(map, options) {
284
287
  this.positions = { ...map };
285
288
  this.emit("graphUiChanged", {
286
289
  def: this.def,
287
290
  change: { type: "moveNodes" },
288
- commit: opts?.commit,
291
+ ...options,
289
292
  });
290
293
  }
291
294
  getPositions() {
292
295
  return { ...this.positions };
293
296
  }
294
- setSelection(sel, opts) {
297
+ setSelection(sel, options) {
295
298
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
296
299
  this.emit("selectionChanged", this.selection);
297
300
  this.emit("graphUiChanged", {
298
301
  def: this.def,
299
302
  change: { type: "selection" },
300
- commit: opts?.commit,
303
+ ...options,
301
304
  });
302
305
  }
303
306
  getSelection() {
@@ -309,7 +312,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
309
312
  /**
310
313
  * Delete all selected nodes and edges.
311
314
  */
312
- deleteSelection() {
315
+ deleteSelection(options) {
313
316
  const selection = this.getSelection();
314
317
  // Delete all selected nodes (this will also remove connected edges)
315
318
  for (const nodeId of selection.nodes) {
@@ -320,14 +323,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
320
323
  this.disconnect(edgeId);
321
324
  }
322
325
  // Clear selection
323
- this.setSelection({ nodes: [], edges: [] });
326
+ this.setSelection({ nodes: [], edges: [] }, options);
324
327
  }
325
- setViewport(viewport, opts) {
328
+ setViewport(viewport, options) {
326
329
  this.viewport = { ...viewport };
327
330
  this.emit("graphUiChanged", {
328
331
  def: this.def,
329
332
  change: { type: "viewport" },
330
- commit: opts?.commit,
333
+ ...options,
331
334
  });
332
335
  }
333
336
  getViewport() {
@@ -472,7 +475,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
472
475
  * Returns the mapping from original node IDs to new node IDs.
473
476
  * Uses copyOutputsFrom to copy outputs from original nodes (like duplicate does).
474
477
  */
475
- pasteCopiedData(data, center) {
478
+ pasteCopiedData(data, center, options) {
476
479
  const nodeIdMap = new Map();
477
480
  const edgeIds = [];
478
481
  // Add nodes
@@ -512,10 +515,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
512
515
  }
513
516
  }
514
517
  // Select the newly pasted nodes
515
- this.setSelection({
516
- nodes: Array.from(nodeIdMap.values()),
517
- edges: edgeIds,
518
- });
518
+ this.setSelection({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
519
519
  return { nodeIdMap, edgeIds };
520
520
  }
521
521
  /**
@@ -625,11 +625,10 @@ class AbstractGraphRunner {
625
625
  this.runtime.resume();
626
626
  // Create and launch new engine (to be implemented by subclasses)
627
627
  await this.createAndLaunchEngine(opts);
628
- // Re-apply staged inputs to new engine
628
+ // Re-apply staged inputs to new engine using runner's setInputs method
629
+ // This ensures consistency and proper handling of staged inputs
629
630
  for (const [nodeId, map] of Object.entries(currentInputs)) {
630
- if (this.engine) {
631
- this.engine.setInputs(nodeId, map);
632
- }
631
+ await this.setInputs(nodeId, map);
633
632
  }
634
633
  }
635
634
  getInputDefaults(def) {
@@ -671,6 +670,21 @@ class AbstractGraphRunner {
671
670
  getRunningEngine() {
672
671
  return this.runningKind;
673
672
  }
673
+ // Optional undo/redo support
674
+ async undo() {
675
+ return false;
676
+ }
677
+ async redo() {
678
+ return false;
679
+ }
680
+ async canUndo() {
681
+ return false;
682
+ }
683
+ async canRedo() {
684
+ return false;
685
+ }
686
+ // Optional commit support
687
+ async commit(_reason) { }
674
688
  }
675
689
 
676
690
  // Counter for generating readable runner IDs
@@ -1295,8 +1309,11 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1295
1309
  this.engine = eng;
1296
1310
  this.runningKind = opts?.engine ?? "push";
1297
1311
  this.emit("status", { running: true, engine: this.runningKind });
1312
+ // Re-apply staged inputs using client.setInputs for consistency
1298
1313
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
1299
- this.engine.setInputs(nodeId, map);
1314
+ await client.setInputs(nodeId, map).catch(() => {
1315
+ // Ignore errors during launch - inputs will be set when user calls setInputs
1316
+ });
1300
1317
  }
1301
1318
  }
1302
1319
  /**
@@ -1338,9 +1355,11 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1338
1355
  this.engine = eng;
1339
1356
  this.runningKind = opts?.engine ?? "push";
1340
1357
  this.emit("status", { running: true, engine: this.runningKind });
1341
- // Re-apply staged inputs to new engine
1358
+ // Re-apply staged inputs using client.setInputs for consistency
1342
1359
  for (const [nodeId, map] of Object.entries(currentInputs)) {
1343
- this.engine.setInputs(nodeId, map);
1360
+ await client.setInputs(nodeId, map).catch(() => {
1361
+ // Ignore errors during engine switch - inputs will be set when user calls setInputs
1362
+ });
1344
1363
  }
1345
1364
  }
1346
1365
  async step() {
@@ -1355,7 +1374,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1355
1374
  const client = await this.ensureClient();
1356
1375
  await client.flush();
1357
1376
  }
1358
- setInputs(nodeId, inputs, options) {
1377
+ async setInputs(nodeId, inputs, options) {
1359
1378
  // Update staged inputs (for getInputs to work correctly)
1360
1379
  if (!this.stagedInputs[nodeId])
1361
1380
  this.stagedInputs[nodeId] = {};
@@ -1367,21 +1386,17 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1367
1386
  this.stagedInputs[nodeId][handle] = value;
1368
1387
  }
1369
1388
  }
1370
- // If engine exists, call directly; otherwise ensure client (fire-and-forget)
1371
- if (this.engine) {
1372
- this.engine.setInputs(nodeId, inputs, options);
1389
+ // Use transport.request instead of transport.send for consistency
1390
+ const client = await this.ensureClient();
1391
+ try {
1392
+ await client.setInputs(nodeId, inputs, options);
1373
1393
  }
1374
- else {
1375
- this.ensureClient()
1376
- .then((client) => {
1377
- client.getEngine().setInputs(nodeId, inputs, options);
1378
- })
1379
- .catch(() => {
1380
- // Emit synthetic events if connection fails
1381
- for (const [handle, value] of Object.entries(inputs)) {
1382
- this.emit("value", { nodeId, handle, value, io: "input" });
1383
- }
1384
- });
1394
+ catch (err) {
1395
+ // Emit synthetic events if connection fails
1396
+ for (const [handle, value] of Object.entries(inputs)) {
1397
+ this.emit("value", { nodeId, handle, value, io: "input" });
1398
+ }
1399
+ throw err;
1385
1400
  }
1386
1401
  }
1387
1402
  async copyOutputs(fromNodeId, toNodeId, options) {
@@ -1416,6 +1431,52 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1416
1431
  const client = await this.ensureClient();
1417
1432
  await client.setExtData(data);
1418
1433
  }
1434
+ async commit(reason) {
1435
+ const client = await this.ensureClient();
1436
+ try {
1437
+ await client.commit(reason);
1438
+ }
1439
+ catch (err) {
1440
+ console.error("[RemoteGraphRunner] Error committing:", err);
1441
+ throw err;
1442
+ }
1443
+ }
1444
+ async undo() {
1445
+ const client = await this.ensureClient();
1446
+ try {
1447
+ return await client.undo();
1448
+ }
1449
+ catch {
1450
+ return false;
1451
+ }
1452
+ }
1453
+ async redo() {
1454
+ const client = await this.ensureClient();
1455
+ try {
1456
+ return await client.redo();
1457
+ }
1458
+ catch {
1459
+ return false;
1460
+ }
1461
+ }
1462
+ async canUndo() {
1463
+ const client = await this.ensureClient();
1464
+ try {
1465
+ return await client.canUndo();
1466
+ }
1467
+ catch {
1468
+ return false;
1469
+ }
1470
+ }
1471
+ async canRedo() {
1472
+ const client = await this.ensureClient();
1473
+ try {
1474
+ return await client.canRedo();
1475
+ }
1476
+ catch {
1477
+ return false;
1478
+ }
1479
+ }
1419
1480
  async snapshotFull() {
1420
1481
  const client = await this.ensureClient();
1421
1482
  try {
@@ -1853,7 +1914,7 @@ function useWorkbenchBridge(wb) {
1853
1914
  wb.connect({
1854
1915
  source: { nodeId: params.source, handle: params.sourceHandle },
1855
1916
  target: { nodeId: params.target, handle: params.targetHandle },
1856
- });
1917
+ }, { commit: true });
1857
1918
  }, [wb]);
1858
1919
  const onNodesChange = useCallback((changes) => {
1859
1920
  // Apply position updates continuously, but mark commit only on drag end
@@ -1896,7 +1957,7 @@ function useWorkbenchBridge(wb) {
1896
1957
  });
1897
1958
  }
1898
1959
  }, [wb]);
1899
- const onEdgesDelete = useCallback((edges) => edges.forEach((e) => wb.disconnect(e.id)), [wb]);
1960
+ const onEdgesDelete = useCallback((edges) => edges.forEach((e, idx) => wb.disconnect(e.id, { commit: idx === edges.length - 1 })), [wb]);
1900
1961
  const onEdgesChange = useCallback((changes) => {
1901
1962
  const current = wb.getSelection();
1902
1963
  const nextEdgeIds = new Set(current.edges);
@@ -1932,8 +1993,7 @@ function useWorkbenchBridge(wb) {
1932
1993
  }
1933
1994
  }, [wb]);
1934
1995
  const onNodesDelete = useCallback((nodes) => {
1935
- for (const n of nodes)
1936
- wb.removeNode(n.id);
1996
+ nodes.forEach((n, idx) => wb.removeNode(n.id, { commit: idx === nodes.length - 1 }));
1937
1997
  }, [wb]);
1938
1998
  return {
1939
1999
  onConnect,
@@ -2626,7 +2686,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2626
2686
  }
2627
2687
  curX += maxWidth + H_GAP;
2628
2688
  }
2629
- wb.setPositions(pos, { commit: true });
2689
+ wb.setPositions(pos, { commit: true, reason: "auto-layout" });
2630
2690
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2631
2691
  const updateEdgeType = useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2632
2692
  const triggerExternal = useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
@@ -2868,7 +2928,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2868
2928
  }
2869
2929
  return add("workbench", "graphChanged")(event);
2870
2930
  });
2871
- const offWbGraphUiChanged = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
2931
+ const offWbGraphUiChangedForLog = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
2872
2932
  const offWbValidationChanged = wb.on("validationChanged", add("workbench", "validationChanged"));
2873
2933
  // Ensure newly added nodes start as invalidated until first evaluation
2874
2934
  const offWbAddNode = wb.on("graphChanged", (e) => {
@@ -2882,39 +2942,94 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2882
2942
  }
2883
2943
  });
2884
2944
  const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
2885
- if (!runner.isRunning())
2945
+ // Build detailed reason from change type
2946
+ let reason = "graph-changed";
2947
+ if (event.change) {
2948
+ const changeType = event.change.type;
2949
+ if (changeType === "addNode") {
2950
+ reason = "add-node";
2951
+ }
2952
+ else if (changeType === "removeNode") {
2953
+ reason = "remove-node";
2954
+ }
2955
+ else if (changeType === "connect") {
2956
+ reason = "connect-edge";
2957
+ }
2958
+ else if (changeType === "disconnect") {
2959
+ reason = "disconnect-edge";
2960
+ }
2961
+ else if (changeType === "updateParams") {
2962
+ reason = "update-node-params";
2963
+ }
2964
+ else if (changeType === "updateEdgeType") {
2965
+ reason = "update-edge-type";
2966
+ }
2967
+ }
2968
+ if (!runner.isRunning()) {
2969
+ if (event.commit) {
2970
+ // If runner not running, commit immediately (no update needed)
2971
+ await runner.commit(reason).catch((err) => {
2972
+ console.error("[WorkbenchContext] Error committing:", err);
2973
+ });
2974
+ }
2886
2975
  return;
2976
+ }
2887
2977
  try {
2888
2978
  if (event.change?.type === "addNode") {
2889
2979
  const { nodeId, inputs, copyOutputsFrom } = event.change;
2890
2980
  if (event.dry) {
2891
2981
  await runner.update(event.def, { dry: true });
2892
2982
  if (inputs) {
2893
- runner.setInputs(nodeId, inputs, { dry: true });
2983
+ await runner.setInputs(nodeId, inputs, { dry: true });
2894
2984
  }
2895
2985
  if (copyOutputsFrom) {
2896
- runner.copyOutputs(copyOutputsFrom, nodeId, { dry: true });
2986
+ await runner.copyOutputs(copyOutputsFrom, nodeId, { dry: true });
2897
2987
  }
2898
2988
  }
2899
2989
  else {
2900
2990
  await runner.update(event.def, { dry: !!inputs });
2901
2991
  if (inputs) {
2902
- runner.setInputs(nodeId, inputs, { dry: false });
2992
+ await runner.setInputs(nodeId, inputs, { dry: false });
2903
2993
  }
2904
2994
  }
2905
2995
  }
2906
2996
  else {
2907
2997
  await runner.update(event.def, { dry: event.dry });
2908
2998
  }
2999
+ if (event.commit) {
3000
+ // Wait for update to complete, then commit
3001
+ await runner.commit(event.reason ?? reason).catch((err) => {
3002
+ console.error("[WorkbenchContext] Error committing after update:", err);
3003
+ });
3004
+ }
2909
3005
  }
2910
3006
  catch (err) {
2911
3007
  console.error("[WorkbenchContext] Error updating graph:", err);
2912
3008
  }
2913
3009
  });
2914
3010
  const offWbdSetValidation = wb.on("validationChanged", (r) => setValidation(r));
2915
- const offWbSelectionChanged = wb.on("selectionChanged", (sel) => {
3011
+ const offWbSelectionChanged = wb.on("selectionChanged", async (sel) => {
2916
3012
  setSelectedNodeId(sel.nodes?.[0]);
2917
3013
  setSelectedEdgeId(sel.edges?.[0]);
3014
+ if (sel.commit) {
3015
+ // Commit on selection change
3016
+ await runner.commit(sel.reason ?? "selection").catch((err) => {
3017
+ console.error("[WorkbenchContext] Error committing selection change:", err);
3018
+ });
3019
+ }
3020
+ });
3021
+ const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
3022
+ // Only commit if commit flag is true (e.g., drag end, not during dragging)
3023
+ if (event.commit) {
3024
+ if (event.change) {
3025
+ event.change.type;
3026
+ }
3027
+ await runner
3028
+ .commit(event.reason ?? "ui-changed")
3029
+ .catch((err) => {
3030
+ console.error("[WorkbenchContext] Error committing UI changes:", err);
3031
+ });
3032
+ }
2918
3033
  });
2919
3034
  const offWbError = wb.on("error", add("workbench", "error"));
2920
3035
  // Registry updates: swap registry and refresh graph validation/UI
@@ -2951,6 +3066,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2951
3066
  offRunnerInvalidate();
2952
3067
  offRunnerStats();
2953
3068
  offWbGraphChanged();
3069
+ offWbGraphUiChangedForLog();
2954
3070
  offWbGraphUiChanged();
2955
3071
  offWbValidationChanged();
2956
3072
  offWbError();
@@ -3126,6 +3242,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3126
3242
  try {
3127
3243
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
3128
3244
  const raw = outputsMap?.[nodeId]?.[handleId];
3245
+ let newNodeId;
3129
3246
  if (!typeId || raw === undefined)
3130
3247
  return;
3131
3248
  const unwrap = (v) => isTypedOutput(v) ? getTypedOutputValue(v) : v;
@@ -3151,23 +3268,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3151
3268
  const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3152
3269
  const inType = getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3153
3270
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3154
- wb.addNode({
3271
+ newNodeId = wb.addNode({
3155
3272
  typeId: singleTarget.nodeTypeId,
3156
3273
  position: { x: pos.x + 180, y: pos.y },
3157
3274
  }, { inputs: { [singleTarget.inputHandle]: coerced } });
3158
- return;
3159
3275
  }
3160
- if (isArray && arrTarget) {
3276
+ else if (isArray && arrTarget) {
3161
3277
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3162
3278
  const inType = getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3163
3279
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3164
- wb.addNode({
3280
+ newNodeId = wb.addNode({
3165
3281
  typeId: arrTarget.nodeTypeId,
3166
3282
  position: { x: pos.x + 180, y: pos.y },
3167
3283
  }, { inputs: { [arrTarget.inputHandle]: coerced } });
3168
- return;
3169
3284
  }
3170
- if (isArray && elemTarget) {
3285
+ else if (isArray && elemTarget) {
3171
3286
  const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
3172
3287
  const inType = getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
3173
3288
  const src = unwrap(raw);
@@ -3179,19 +3294,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3179
3294
  for (let idx = 0; idx < coercedItems.length; idx++) {
3180
3295
  const col = idx % COLS;
3181
3296
  const row = Math.floor(idx / COLS);
3182
- wb.addNode({
3297
+ newNodeId = wb.addNode({
3183
3298
  typeId: elemTarget.nodeTypeId,
3184
3299
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3185
3300
  }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3186
3301
  }
3187
- return;
3302
+ }
3303
+ if (newNodeId) {
3304
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "bake" });
3188
3305
  }
3189
3306
  }
3190
3307
  catch { }
3191
3308
  };
3192
3309
  return {
3193
3310
  onDelete: () => {
3194
- wb.removeNode(nodeId);
3311
+ wb.removeNode(nodeId, { commit: true });
3195
3312
  onClose();
3196
3313
  },
3197
3314
  onDuplicate: async () => {
@@ -3216,10 +3333,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3216
3333
  dry: true,
3217
3334
  });
3218
3335
  // Select the newly duplicated node
3219
- wb.setSelection({
3220
- nodes: [newNodeId],
3221
- edges: [],
3222
- });
3336
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate" });
3223
3337
  onClose();
3224
3338
  },
3225
3339
  onDuplicateWithEdges: async () => {
@@ -3252,10 +3366,9 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3252
3366
  }, { dry: true });
3253
3367
  }
3254
3368
  // Select the newly duplicated node and edges
3255
- wb.setSelection({
3256
- nodes: [newNodeId],
3257
- edges: [],
3258
- });
3369
+ if (newNodeId) {
3370
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate-with-edges" });
3371
+ }
3259
3372
  onClose();
3260
3373
  },
3261
3374
  onRunPull: async () => {
@@ -3329,7 +3442,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3329
3442
  onClose();
3330
3443
  },
3331
3444
  onDelete: () => {
3332
- wb.deleteSelection();
3445
+ wb.deleteSelection({ commit: true, reason: "delete-selection" });
3333
3446
  onClose();
3334
3447
  },
3335
3448
  onClose,
@@ -3338,10 +3451,24 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3338
3451
  /**
3339
3452
  * Creates base default context menu handlers.
3340
3453
  */
3341
- function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste) {
3454
+ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData) {
3455
+ // Wrap paste handler to clear storage after paste
3456
+ const wrappedOnPaste = onPaste && getCopiedData && clearCopiedData
3457
+ ? (position) => {
3458
+ onPaste(position);
3459
+ clearCopiedData();
3460
+ }
3461
+ : onPaste;
3462
+ // Function to check if paste data exists (called dynamically when menu opens)
3463
+ const hasPasteData = getCopiedData ? () => !!getCopiedData() : undefined;
3342
3464
  return {
3343
3465
  onAddNode,
3344
- onPaste,
3466
+ onPaste: wrappedOnPaste,
3467
+ hasPasteData,
3468
+ onUndo: runner ? () => runner.undo().then(() => onClose()) : undefined,
3469
+ onRedo: runner ? () => runner.redo().then(() => onClose()) : undefined,
3470
+ canUndo: runner ? () => runner.canUndo().catch(() => false) : undefined,
3471
+ canRedo: runner ? () => runner.canRedo().catch(() => false) : undefined,
3345
3472
  onClose,
3346
3473
  };
3347
3474
  }
@@ -3865,13 +3992,48 @@ function DefaultNodeContent({ data, isConnectable, }) {
3865
3992
  } })] }));
3866
3993
  }
3867
3994
 
3868
- function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3995
+ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enableKeyboardShortcuts = true, keyboardShortcuts = {
3996
+ undo: "⌘/Ctrl + Z",
3997
+ redo: "⌘/Ctrl + Shift + Z",
3998
+ paste: "⌘/Ctrl + V",
3999
+ }, }) {
3869
4000
  const rf = useReactFlow();
3870
4001
  const [query, setQuery] = useState("");
4002
+ const [canUndo, setCanUndo] = useState(false);
4003
+ const [canRedo, setCanRedo] = useState(false);
4004
+ const [hasPasteData, setHasPasteData] = useState(false);
3871
4005
  const q = query.trim().toLowerCase();
3872
4006
  const filteredIds = q
3873
4007
  ? nodeIds.filter((id) => id.toLowerCase().includes(q))
3874
4008
  : nodeIds;
4009
+ // Check undo/redo availability and paste data when menu opens
4010
+ useEffect(() => {
4011
+ if (!open)
4012
+ return;
4013
+ let cancelled = false;
4014
+ const checkAvailability = async () => {
4015
+ if (handlers.canUndo) {
4016
+ const result = await handlers.canUndo();
4017
+ if (!cancelled)
4018
+ setCanUndo(result);
4019
+ }
4020
+ if (handlers.canRedo) {
4021
+ const result = await handlers.canRedo();
4022
+ if (!cancelled)
4023
+ setCanRedo(result);
4024
+ }
4025
+ // Check paste data dynamically
4026
+ if (handlers.hasPasteData) {
4027
+ const result = handlers.hasPasteData();
4028
+ if (!cancelled)
4029
+ setHasPasteData(result);
4030
+ }
4031
+ };
4032
+ checkAvailability();
4033
+ return () => {
4034
+ cancelled = true;
4035
+ };
4036
+ }, [open, handlers.canUndo, handlers.canRedo, handlers.hasPasteData]);
3875
4037
  const root = { __children: {} };
3876
4038
  for (const id of filteredIds) {
3877
4039
  const parts = id.split(".");
@@ -3936,6 +4098,12 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3936
4098
  handlers.onPaste(p);
3937
4099
  handlers.onClose();
3938
4100
  };
4101
+ // Helper to format shortcut for current platform
4102
+ const formatShortcut = (shortcut) => {
4103
+ const isMac = typeof navigator !== "undefined" &&
4104
+ navigator.userAgent.toLowerCase().includes("mac");
4105
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4106
+ };
3939
4107
  const renderTree = (tree, path = []) => {
3940
4108
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
3941
4109
  return (jsx("div", { children: entries.map(([key, child]) => {
@@ -3953,10 +4121,17 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3953
4121
  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) => {
3954
4122
  e.preventDefault();
3955
4123
  e.stopPropagation();
3956
- }, children: [handlers.onPaste && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed", onClick: handlePaste, children: "Paste" })), handlers.onPaste && 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 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" })) })] }));
4124
+ }, children: [hasPasteData && handlers.onPaste && (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between", onClick: handlePaste, children: [jsx("span", { children: "Paste" }), enableKeyboardShortcuts && keyboardShortcuts.paste && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.paste) }))] })), (handlers.onUndo || handlers.onRedo) && (jsxs(Fragment, { children: [hasPasteData && handlers.onPaste && (jsx("div", { className: "h-px bg-gray-200 my-1" })), handlers.onUndo && (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between", onClick: handlers.onUndo, disabled: !canUndo, children: [jsx("span", { children: "Undo" }), enableKeyboardShortcuts && keyboardShortcuts.undo && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.undo) }))] })), handlers.onRedo && (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between", onClick: handlers.onRedo, disabled: !canRedo, children: [jsx("span", { children: "Redo" }), enableKeyboardShortcuts && keyboardShortcuts.redo && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.redo) }))] }))] })), hasPasteData &&
4125
+ handlers.onPaste &&
4126
+ !handlers.onUndo &&
4127
+ !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" })) })] }));
3957
4128
  }
3958
4129
 
3959
- function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, }) {
4130
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, enableKeyboardShortcuts = true, keyboardShortcuts = {
4131
+ copy: "⌘/Ctrl + C",
4132
+ duplicate: "⌘/Ctrl + D",
4133
+ delete: "Delete",
4134
+ }, }) {
3960
4135
  const ref = useRef(null);
3961
4136
  // outside click + ESC
3962
4137
  useEffect(() => {
@@ -3983,6 +4158,12 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
3983
4158
  if (open)
3984
4159
  ref.current?.focus();
3985
4160
  }, [open]);
4161
+ // Helper to format shortcut for current platform
4162
+ const formatShortcut = (shortcut) => {
4163
+ const isMac = typeof navigator !== "undefined" &&
4164
+ navigator.userAgent.toLowerCase().includes("mac");
4165
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4166
+ };
3986
4167
  if (!open || !clientPos || !nodeId)
3987
4168
  return null;
3988
4169
  // clamp
@@ -3994,10 +4175,13 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
3994
4175
  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) => {
3995
4176
  e.preventDefault();
3996
4177
  e.stopPropagation();
3997
- }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicate, children: "Duplicate" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopy, children: "Copy" }), 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)))] }))] }));
4178
+ }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onDelete, children: [jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] }), jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onDuplicate, children: [jsx("span", { children: "Duplicate" }), enableKeyboardShortcuts && keyboardShortcuts.duplicate && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.duplicate) }))] }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onCopy, children: [jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), 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)))] }))] }));
3998
4179
  }
3999
4180
 
4000
- function SelectionContextMenu({ open, clientPos, handlers, }) {
4181
+ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
4182
+ copy: "⌘/Ctrl + C",
4183
+ delete: "Delete",
4184
+ }, }) {
4001
4185
  const ref = useRef(null);
4002
4186
  // Close on outside click and on ESC
4003
4187
  useEffect(() => {
@@ -4024,6 +4208,12 @@ function SelectionContextMenu({ open, clientPos, handlers, }) {
4024
4208
  if (open)
4025
4209
  ref.current?.focus();
4026
4210
  }, [open]);
4211
+ // Helper to format shortcut for current platform
4212
+ const formatShortcut = (shortcut) => {
4213
+ const isMac = typeof navigator !== "undefined" &&
4214
+ navigator.userAgent.toLowerCase().includes("mac");
4215
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4216
+ };
4027
4217
  if (!open || !clientPos)
4028
4218
  return null;
4029
4219
  // Clamp menu position to viewport
@@ -4035,7 +4225,7 @@ function SelectionContextMenu({ open, clientPos, handlers, }) {
4035
4225
  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) => {
4036
4226
  e.preventDefault();
4037
4227
  e.stopPropagation();
4038
- }, children: [jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopy, children: "Copy" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" })] }));
4228
+ }, children: [jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onCopy, children: [jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100 flex items-center justify-between", onClick: handlers.onDelete, children: [jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] })] }));
4039
4229
  }
4040
4230
 
4041
4231
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
@@ -4373,7 +4563,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4373
4563
  setNodeMenuOpen(false);
4374
4564
  setSelectionMenuOpen(false);
4375
4565
  };
4376
- const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs }), [wb]);
4566
+ const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs, commit: true }), [wb]);
4377
4567
  const onCloseMenu = useCallback(() => {
4378
4568
  setMenuOpen(false);
4379
4569
  }, []);
@@ -4405,14 +4595,14 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4405
4595
  const data = storage.get();
4406
4596
  if (!data)
4407
4597
  return;
4408
- wb.pasteCopiedData(data, position);
4598
+ wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4409
4599
  onCloseMenu();
4410
- });
4600
+ }, runner, () => storage.get(), () => storage.set(null));
4411
4601
  if (overrides?.getDefaultContextMenuHandlers) {
4412
4602
  return overrides.getDefaultContextMenuHandlers(wb, baseHandlers);
4413
4603
  }
4414
4604
  return baseHandlers;
4415
- }, [addNodeAt, onCloseMenu, overrides, wb]);
4605
+ }, [addNodeAt, onCloseMenu, overrides, wb, runner]);
4416
4606
  const selectionContextMenuHandlers = useMemo(() => {
4417
4607
  // Get storage from override or use workbench's internal storage
4418
4608
  const storage = overrides?.getCopiedDataStorage
@@ -4426,9 +4616,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4426
4616
  }, runner);
4427
4617
  if (overrides?.getSelectionContextMenuHandlers) {
4428
4618
  const selection = wb.getSelection();
4429
- return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, {
4430
- getDefaultNodeSize: overrides.getDefaultNodeSize,
4431
- });
4619
+ return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, { getDefaultNodeSize: overrides.getDefaultNodeSize });
4432
4620
  }
4433
4621
  return baseHandlers;
4434
4622
  }, [wb, runner, overrides, onCloseSelectionMenu]);
@@ -4467,6 +4655,116 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4467
4655
  return [];
4468
4656
  return getBakeableOutputs(nodeAtMenu, wb, registry, outputTypesMap);
4469
4657
  }, [nodeAtMenu, wb, registry, outputTypesMap]);
4658
+ // Keyboard shortcuts configuration
4659
+ const enableKeyboardShortcuts = overrides?.enableKeyboardShortcuts !== false; // Default to true
4660
+ const keyboardShortcuts = overrides?.keyboardShortcuts || {
4661
+ undo: "⌘/Ctrl + Z",
4662
+ redo: "⌘/Ctrl + Shift + Z",
4663
+ copy: "⌘/Ctrl + C",
4664
+ paste: "⌘/Ctrl + V",
4665
+ duplicate: "⌘/Ctrl + D",
4666
+ delete: "Delete",
4667
+ };
4668
+ // Keyboard shortcut handler
4669
+ useEffect(() => {
4670
+ if (!enableKeyboardShortcuts)
4671
+ return;
4672
+ const handleKeyDown = async (e) => {
4673
+ // Ignore if typing in input/textarea
4674
+ const target = e.target;
4675
+ if (target.tagName === "INPUT" ||
4676
+ target.tagName === "TEXTAREA" ||
4677
+ target.isContentEditable) {
4678
+ return;
4679
+ }
4680
+ // Detect Mac platform using userAgent (navigator.platform is deprecated)
4681
+ const isMac = typeof navigator !== "undefined" &&
4682
+ navigator.userAgent.toLowerCase().includes("mac");
4683
+ const modKey = isMac ? e.metaKey : e.ctrlKey;
4684
+ const key = e.key.toLowerCase();
4685
+ // Undo: Cmd/Ctrl + Z
4686
+ if (modKey && key === "z" && !e.shiftKey && !e.altKey) {
4687
+ e.preventDefault();
4688
+ if (runner &&
4689
+ "onUndo" in defaultContextMenuHandlers &&
4690
+ defaultContextMenuHandlers.onUndo) {
4691
+ const canUndo = await runner.canUndo().catch(() => false);
4692
+ if (canUndo) {
4693
+ defaultContextMenuHandlers.onUndo();
4694
+ }
4695
+ }
4696
+ return;
4697
+ }
4698
+ // Redo: Cmd/Ctrl + Shift + Z
4699
+ if (modKey && e.shiftKey && key === "z" && !e.altKey) {
4700
+ e.preventDefault();
4701
+ if (runner &&
4702
+ "onRedo" in defaultContextMenuHandlers &&
4703
+ defaultContextMenuHandlers.onRedo) {
4704
+ const canRedo = await runner.canRedo().catch(() => false);
4705
+ if (canRedo) {
4706
+ defaultContextMenuHandlers.onRedo();
4707
+ }
4708
+ }
4709
+ return;
4710
+ }
4711
+ // Copy: Cmd/Ctrl + C
4712
+ if (modKey && key === "c" && !e.shiftKey && !e.altKey) {
4713
+ const selection = wb.getSelection();
4714
+ if (selection.nodes.length > 0 || selection.edges.length > 0) {
4715
+ e.preventDefault();
4716
+ // If single node selected, use node context menu handler; otherwise use selection handler
4717
+ if (selection.nodes.length === 1 && nodeContextMenuHandlers?.onCopy) {
4718
+ nodeContextMenuHandlers.onCopy();
4719
+ }
4720
+ else if (selectionContextMenuHandlers.onCopy) {
4721
+ selectionContextMenuHandlers.onCopy();
4722
+ }
4723
+ }
4724
+ return;
4725
+ }
4726
+ // Duplicate: Cmd/Ctrl + D
4727
+ if (modKey && key === "d" && !e.shiftKey && !e.altKey) {
4728
+ const selection = wb.getSelection();
4729
+ if (selection.nodes.length === 1 &&
4730
+ nodeContextMenuHandlers?.onDuplicate) {
4731
+ e.preventDefault();
4732
+ nodeContextMenuHandlers.onDuplicate();
4733
+ }
4734
+ return;
4735
+ }
4736
+ // Paste: Cmd/Ctrl + V
4737
+ if (modKey && key === "v" && !e.shiftKey && !e.altKey) {
4738
+ e.preventDefault();
4739
+ if ("hasPasteData" in defaultContextMenuHandlers &&
4740
+ defaultContextMenuHandlers.hasPasteData &&
4741
+ defaultContextMenuHandlers.hasPasteData() &&
4742
+ "onPaste" in defaultContextMenuHandlers &&
4743
+ defaultContextMenuHandlers.onPaste) {
4744
+ const center = rfInstanceRef.current?.screenToFlowPosition({
4745
+ x: window.innerWidth / 2,
4746
+ y: window.innerHeight / 2,
4747
+ }) || { x: 0, y: 0 };
4748
+ defaultContextMenuHandlers.onPaste(center);
4749
+ }
4750
+ return;
4751
+ }
4752
+ // Note: Delete/Backspace is handled by ReactFlow's deleteKeyCode prop
4753
+ // which triggers onNodesDelete/onEdgesDelete, so we don't need to handle it here
4754
+ };
4755
+ window.addEventListener("keydown", handleKeyDown);
4756
+ return () => {
4757
+ window.removeEventListener("keydown", handleKeyDown);
4758
+ };
4759
+ }, [
4760
+ enableKeyboardShortcuts,
4761
+ wb,
4762
+ runner,
4763
+ defaultContextMenuHandlers,
4764
+ selectionContextMenuHandlers,
4765
+ nodeContextMenuHandlers,
4766
+ rfInstanceRef,
4767
+ ]);
4470
4768
  // Get custom renderers from UI extension registry (reactive to uiVersion changes)
4471
4769
  const { BackgroundRenderer, MinimapRenderer, ControlsRenderer, DefaultContextMenuRenderer, NodeContextMenuRenderer, connectionLineRenderer, } = useMemo(() => {
4472
4770
  return {
@@ -4481,7 +4779,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4481
4779
  const onMoveEnd = useCallback(() => {
4482
4780
  if (rfInstanceRef.current) {
4483
4781
  const viewport = rfInstanceRef.current.getViewport();
4484
- wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { commit: true });
4782
+ wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom });
4485
4783
  }
4486
4784
  }, [wb]);
4487
4785
  const viewportRef = useRef(null);
@@ -4513,9 +4811,13 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4513
4811
  zoom: savedViewport.zoom,
4514
4812
  });
4515
4813
  }
4516
- }, 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", fitView: true, 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: registry, nodeIds: nodeIds })) : (jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds })), !!nodeAtMenu &&
4814
+ }, 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", fitView: true, 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: registry, nodeIds: nodeIds, ...(enableKeyboardShortcuts !== false
4815
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
4816
+ : {}) })) : (jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
4517
4817
  nodeContextMenuHandlers &&
4518
- (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs })) : (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs }))), selectionMenuOpen && selectionMenuPos && (jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers }))] }) }) }));
4818
+ (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, ...(enableKeyboardShortcuts !== false
4819
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
4820
+ : {}) })) : (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))), selectionMenuOpen && selectionMenuPos && (jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))] }) }) }));
4519
4821
  });
4520
4822
 
4521
4823
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {