@bian-womp/spark-workbench 0.2.69 → 0.2.71

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 (77) hide show
  1. package/lib/cjs/index.cjs +377 -177
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +35 -14
  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/index.d.ts +1 -1
  8. package/lib/cjs/src/index.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  10. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +2 -1
  11. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/context-menu/ContextMenuButton.d.ts +9 -0
  14. package/lib/cjs/src/misc/context-menu/ContextMenuButton.d.ts.map +1 -0
  15. package/lib/cjs/src/misc/{context → context-menu}/ContextMenuHandlers.d.ts +8 -2
  16. package/lib/cjs/src/misc/context-menu/ContextMenuHandlers.d.ts.map +1 -0
  17. package/lib/cjs/src/misc/{context → context-menu}/ContextMenuHelpers.d.ts +2 -1
  18. package/lib/cjs/src/misc/context-menu/ContextMenuHelpers.d.ts.map +1 -0
  19. package/lib/cjs/src/misc/{DefaultContextMenu.d.ts → context-menu/DefaultContextMenu.d.ts} +1 -1
  20. package/lib/cjs/src/misc/context-menu/DefaultContextMenu.d.ts.map +1 -0
  21. package/lib/cjs/src/misc/context-menu/NodeContextMenu.d.ts +3 -0
  22. package/lib/cjs/src/misc/context-menu/NodeContextMenu.d.ts.map +1 -0
  23. package/lib/cjs/src/misc/{SelectionContextMenu.d.ts → context-menu/SelectionContextMenu.d.ts} +1 -1
  24. package/lib/cjs/src/misc/context-menu/SelectionContextMenu.d.ts.map +1 -0
  25. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  26. package/lib/cjs/src/misc/load.d.ts.map +1 -1
  27. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +2 -4
  28. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  29. package/lib/cjs/src/runtime/IGraphRunner.d.ts +3 -4
  30. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  31. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +2 -4
  32. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  33. package/lib/esm/index.js +377 -177
  34. package/lib/esm/index.js.map +1 -1
  35. package/lib/esm/src/core/InMemoryWorkbench.d.ts +35 -14
  36. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  37. package/lib/esm/src/core/contracts.d.ts +5 -0
  38. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  39. package/lib/esm/src/index.d.ts +1 -1
  40. package/lib/esm/src/index.d.ts.map +1 -1
  41. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  42. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +2 -1
  43. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  44. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  45. package/lib/esm/src/misc/context-menu/ContextMenuButton.d.ts +9 -0
  46. package/lib/esm/src/misc/context-menu/ContextMenuButton.d.ts.map +1 -0
  47. package/lib/esm/src/misc/{context → context-menu}/ContextMenuHandlers.d.ts +8 -2
  48. package/lib/esm/src/misc/context-menu/ContextMenuHandlers.d.ts.map +1 -0
  49. package/lib/esm/src/misc/{context → context-menu}/ContextMenuHelpers.d.ts +2 -1
  50. package/lib/esm/src/misc/context-menu/ContextMenuHelpers.d.ts.map +1 -0
  51. package/lib/esm/src/misc/{DefaultContextMenu.d.ts → context-menu/DefaultContextMenu.d.ts} +1 -1
  52. package/lib/esm/src/misc/context-menu/DefaultContextMenu.d.ts.map +1 -0
  53. package/lib/esm/src/misc/context-menu/NodeContextMenu.d.ts +3 -0
  54. package/lib/esm/src/misc/context-menu/NodeContextMenu.d.ts.map +1 -0
  55. package/lib/esm/src/misc/{SelectionContextMenu.d.ts → context-menu/SelectionContextMenu.d.ts} +1 -1
  56. package/lib/esm/src/misc/context-menu/SelectionContextMenu.d.ts.map +1 -0
  57. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  58. package/lib/esm/src/misc/load.d.ts.map +1 -1
  59. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +2 -4
  60. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  61. package/lib/esm/src/runtime/IGraphRunner.d.ts +3 -4
  62. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  63. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +2 -4
  64. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  65. package/package.json +4 -4
  66. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +0 -1
  67. package/lib/cjs/src/misc/NodeContextMenu.d.ts +0 -3
  68. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +0 -1
  69. package/lib/cjs/src/misc/SelectionContextMenu.d.ts.map +0 -1
  70. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +0 -1
  71. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +0 -1
  72. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +0 -1
  73. package/lib/esm/src/misc/NodeContextMenu.d.ts +0 -3
  74. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +0 -1
  75. package/lib/esm/src/misc/SelectionContextMenu.d.ts.map +0 -1
  76. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +0 -1
  77. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +0 -1
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';
@@ -122,13 +123,15 @@ class InMemoryWorkbench extends AbstractWorkbench {
122
123
  constructor() {
123
124
  super(...arguments);
124
125
  this.def = { nodes: [], edges: [] };
125
- this.positions = {};
126
126
  this.listeners = new Map();
127
+ this.positions = {};
127
128
  this.selection = {
128
129
  nodes: [],
129
130
  edges: [],
130
131
  };
131
132
  this.viewport = null;
133
+ this.runtimeState = null;
134
+ this.historyState = undefined;
132
135
  this.copiedData = null;
133
136
  }
134
137
  setRegistry(registry) {
@@ -208,18 +211,19 @@ class InMemoryWorkbench extends AbstractWorkbench {
208
211
  inputs: options?.inputs,
209
212
  copyOutputsFrom: options?.copyOutputsFrom,
210
213
  },
211
- dry: options?.dry,
214
+ ...lod.pick(options, ["dry", "commit", "reason"]),
212
215
  });
213
216
  this.refreshValidation();
214
217
  return id;
215
218
  }
216
- removeNode(nodeId) {
219
+ removeNode(nodeId, options) {
217
220
  this.def.nodes = this.def.nodes.filter((n) => n.nodeId !== nodeId);
218
221
  this.def.edges = this.def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
219
222
  delete this.positions[nodeId];
220
223
  this.emit("graphChanged", {
221
224
  def: this.def,
222
225
  change: { type: "removeNode", nodeId },
226
+ ...options,
223
227
  });
224
228
  this.refreshValidation();
225
229
  }
@@ -234,16 +238,17 @@ class InMemoryWorkbench extends AbstractWorkbench {
234
238
  this.emit("graphChanged", {
235
239
  def: this.def,
236
240
  change: { type: "connect", edgeId: id },
237
- dry: options?.dry,
241
+ ...options,
238
242
  });
239
243
  this.refreshValidation();
240
244
  return id;
241
245
  }
242
- disconnect(edgeId) {
246
+ disconnect(edgeId, options) {
243
247
  this.def.edges = this.def.edges.filter((e) => e.id !== edgeId);
244
248
  this.emit("graphChanged", {
245
249
  def: this.def,
246
250
  change: { type: "disconnect", edgeId },
251
+ ...options,
247
252
  });
248
253
  this.emit("validationChanged", this.validate());
249
254
  }
@@ -272,32 +277,26 @@ class InMemoryWorkbench extends AbstractWorkbench {
272
277
  });
273
278
  }
274
279
  // Position and selection APIs for React Flow bridge
275
- setPosition(nodeId, pos, opts) {
276
- this.positions[nodeId] = pos;
277
- this.emit("graphUiChanged", {
278
- def: this.def,
279
- change: { type: "moveNode", nodeId, pos },
280
- commit: !!opts?.commit === true,
281
- });
282
- }
283
- setPositions(map, opts) {
280
+ setPositions(map, options) {
284
281
  this.positions = { ...map };
285
282
  this.emit("graphUiChanged", {
286
283
  def: this.def,
287
284
  change: { type: "moveNodes" },
288
- commit: opts?.commit,
285
+ ...options,
289
286
  });
290
287
  }
291
288
  getPositions() {
292
289
  return { ...this.positions };
293
290
  }
294
- setSelection(sel, opts) {
291
+ setSelection(sel, options) {
292
+ if (lod.isEqual(this.selection, sel))
293
+ return;
295
294
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
296
295
  this.emit("selectionChanged", this.selection);
297
296
  this.emit("graphUiChanged", {
298
297
  def: this.def,
299
298
  change: { type: "selection" },
300
- commit: opts?.commit,
299
+ ...options,
301
300
  });
302
301
  }
303
302
  getSelection() {
@@ -309,7 +308,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
309
308
  /**
310
309
  * Delete all selected nodes and edges.
311
310
  */
312
- deleteSelection() {
311
+ deleteSelection(options) {
313
312
  const selection = this.getSelection();
314
313
  // Delete all selected nodes (this will also remove connected edges)
315
314
  for (const nodeId of selection.nodes) {
@@ -320,14 +319,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
320
319
  this.disconnect(edgeId);
321
320
  }
322
321
  // Clear selection
323
- this.setSelection({ nodes: [], edges: [] });
322
+ this.setSelection({ nodes: [], edges: [] }, options);
324
323
  }
325
- setViewport(viewport, opts) {
324
+ setViewport(viewport, options) {
326
325
  this.viewport = { ...viewport };
327
326
  this.emit("graphUiChanged", {
328
327
  def: this.def,
329
328
  change: { type: "viewport" },
330
- commit: opts?.commit,
329
+ ...options,
331
330
  });
332
331
  }
333
332
  getViewport() {
@@ -369,6 +368,27 @@ class InMemoryWorkbench extends AbstractWorkbench {
369
368
  this.viewport = { ...ui.viewport };
370
369
  }
371
370
  }
371
+ getRuntimeState() {
372
+ return this.runtimeState ? { ...this.runtimeState } : null;
373
+ }
374
+ setRuntimeState(runtime) {
375
+ this.runtimeState = runtime ? { ...runtime } : null;
376
+ }
377
+ getHistory() {
378
+ return this.historyState;
379
+ }
380
+ setHistory(history) {
381
+ this.historyState = history;
382
+ }
383
+ getNodeRuntimeMetadata(nodeId) {
384
+ return this.runtimeState?.nodes[nodeId];
385
+ }
386
+ updateNodeRuntimeMetadata(nodeId, updater) {
387
+ const current = this.runtimeState ?? { nodes: {} };
388
+ const nodeMeta = current.nodes[nodeId] ?? {};
389
+ const updated = updater({ ...nodeMeta });
390
+ this.runtimeState = { nodes: { ...current.nodes, [nodeId]: updated } };
391
+ }
372
392
  on(event, handler) {
373
393
  if (!this.listeners.has(event))
374
394
  this.listeners.set(event, new Set());
@@ -472,7 +492,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
472
492
  * Returns the mapping from original node IDs to new node IDs.
473
493
  * Uses copyOutputsFrom to copy outputs from original nodes (like duplicate does).
474
494
  */
475
- pasteCopiedData(data, center) {
495
+ pasteCopiedData(data, center, options) {
476
496
  const nodeIdMap = new Map();
477
497
  const edgeIds = [];
478
498
  // Add nodes
@@ -512,10 +532,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
512
532
  }
513
533
  }
514
534
  // Select the newly pasted nodes
515
- this.setSelection({
516
- nodes: Array.from(nodeIdMap.values()),
517
- edges: edgeIds,
518
- });
535
+ this.setSelection({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
519
536
  return { nodeIdMap, edgeIds };
520
537
  }
521
538
  /**
@@ -677,14 +694,10 @@ class AbstractGraphRunner {
677
694
  async redo() {
678
695
  return false;
679
696
  }
680
- async canUndo() {
681
- return false;
682
- }
683
- async canRedo() {
684
- return false;
685
- }
686
697
  // Optional commit support
687
- async commit() { }
698
+ async commit(_reason) {
699
+ return undefined;
700
+ }
688
701
  }
689
702
 
690
703
  // Counter for generating readable runner IDs
@@ -1431,10 +1444,11 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1431
1444
  const client = await this.ensureClient();
1432
1445
  await client.setExtData(data);
1433
1446
  }
1434
- async commit() {
1447
+ async commit(reason) {
1435
1448
  const client = await this.ensureClient();
1436
1449
  try {
1437
- await client.commit();
1450
+ const history = await client.commit(reason);
1451
+ return history;
1438
1452
  }
1439
1453
  catch (err) {
1440
1454
  console.error("[RemoteGraphRunner] Error committing:", err);
@@ -1459,24 +1473,6 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1459
1473
  return false;
1460
1474
  }
1461
1475
  }
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
- }
1480
1476
  async snapshotFull() {
1481
1477
  const client = await this.ensureClient();
1482
1478
  try {
@@ -1914,13 +1910,17 @@ function useWorkbenchBridge(wb) {
1914
1910
  wb.connect({
1915
1911
  source: { nodeId: params.source, handle: params.sourceHandle },
1916
1912
  target: { nodeId: params.target, handle: params.targetHandle },
1917
- });
1913
+ }, { commit: true });
1918
1914
  }, [wb]);
1919
1915
  const onNodesChange = useCallback((changes) => {
1920
1916
  // Apply position updates continuously, but mark commit only on drag end
1917
+ const positions = {};
1918
+ let commit = false;
1921
1919
  changes.forEach((c) => {
1922
1920
  if (c.type === "position" && c.position) {
1923
- wb.setPosition(c.id, c.position, { commit: !c.dragging });
1921
+ positions[c.id] = c.position;
1922
+ if (!c.dragging)
1923
+ commit = true;
1924
1924
  }
1925
1925
  });
1926
1926
  // Derive next node selection from change set
@@ -1951,13 +1951,13 @@ function useWorkbenchBridge(wb) {
1951
1951
  }
1952
1952
  }
1953
1953
  if (selectionChanged) {
1954
- wb.setSelection({
1955
- nodes: Array.from(nextNodeIds),
1956
- edges: current.edges,
1957
- });
1954
+ wb.setSelection({ nodes: Array.from(nextNodeIds), edges: current.edges }, { commit: !(Object.keys(positions).length && commit) });
1955
+ }
1956
+ if (Object.keys(positions).length > 0) {
1957
+ wb.setPositions(positions, { commit });
1958
1958
  }
1959
1959
  }, [wb]);
1960
- 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]);
1961
1961
  const onEdgesChange = useCallback((changes) => {
1962
1962
  const current = wb.getSelection();
1963
1963
  const nextEdgeIds = new Set(current.edges);
@@ -1986,15 +1986,11 @@ function useWorkbenchBridge(wb) {
1986
1986
  }
1987
1987
  }
1988
1988
  if (selectionChanged) {
1989
- wb.setSelection({
1990
- nodes: current.nodes,
1991
- edges: Array.from(nextEdgeIds),
1992
- });
1989
+ wb.setSelection({ nodes: current.nodes, edges: Array.from(nextEdgeIds) }, { commit: true });
1993
1990
  }
1994
1991
  }, [wb]);
1995
1992
  const onNodesDelete = useCallback((nodes) => {
1996
- for (const n of nodes)
1997
- wb.removeNode(n.id);
1993
+ nodes.forEach((n, idx) => wb.removeNode(n.id, { commit: idx === nodes.length - 1 }));
1998
1994
  }, [wb]);
1999
1995
  return {
2000
1996
  onConnect,
@@ -2438,6 +2434,7 @@ async function download(wb, runner) {
2438
2434
  try {
2439
2435
  const def = wb.export();
2440
2436
  const uiState = wb.getUIState();
2437
+ const runtimeState = wb.getRuntimeState();
2441
2438
  let snapshot;
2442
2439
  if (runner.isRunning()) {
2443
2440
  const fullSnapshot = await runner.snapshotFull();
@@ -2447,6 +2444,7 @@ async function download(wb, runner) {
2447
2444
  extData: {
2448
2445
  ...(fullSnapshot.extData || {}),
2449
2446
  ui: uiState,
2447
+ runtime: runtimeState || undefined,
2450
2448
  },
2451
2449
  };
2452
2450
  }
@@ -2457,7 +2455,7 @@ async function download(wb, runner) {
2457
2455
  inputs,
2458
2456
  outputs: {},
2459
2457
  environment: {},
2460
- extData: { ui: uiState },
2458
+ extData: { ui: uiState, runtime: runtimeState || undefined },
2461
2459
  };
2462
2460
  }
2463
2461
  downloadJSON(snapshot, `spark-snapshot-${generateTimestamp()}.json`);
@@ -2483,6 +2481,9 @@ async function upload(parsed, wb, runner) {
2483
2481
  if (extData.ui && typeof extData.ui === "object") {
2484
2482
  wb.setUIState(extData.ui);
2485
2483
  }
2484
+ if (extData.runtime && typeof extData.runtime === "object") {
2485
+ wb.setRuntimeState(extData.runtime);
2486
+ }
2486
2487
  if (runner.isRunning()) {
2487
2488
  await runner.applySnapshotFull({
2488
2489
  def,
@@ -2510,6 +2511,18 @@ function useWorkbenchContext() {
2510
2511
  return ctx;
2511
2512
  }
2512
2513
 
2514
+ // Helper to compute invalidated status from runtime metadata
2515
+ function computeInvalidatedFromMetadata(metadata) {
2516
+ if (!metadata)
2517
+ return true;
2518
+ const { lastSuccessAt, lastInputAt, lastRunAt } = metadata;
2519
+ if (!lastSuccessAt && !lastRunAt)
2520
+ return true;
2521
+ if (!lastInputAt || Object.keys(lastInputAt).length === 0)
2522
+ return false;
2523
+ const maxInputTime = Math.max(...Object.values(lastInputAt));
2524
+ return maxInputTime > (lastSuccessAt ?? lastRunAt ?? 0);
2525
+ }
2513
2526
  function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVersion, children, }) {
2514
2527
  const [nodeStatus, setNodeStatus] = useState({});
2515
2528
  const [edgeStatus, setEdgeStatus] = useState({});
@@ -2600,27 +2613,35 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2600
2613
  }
2601
2614
  return out;
2602
2615
  }, [def, outputsMap, registry]);
2603
- // Initialize nodes as invalidated by default until first successful run
2616
+ // Initialize nodes and derive invalidated status from persisted metadata
2604
2617
  useEffect(() => {
2618
+ const workbenchRuntimeState = wb.getRuntimeState() ?? { nodes: {} };
2605
2619
  setNodeStatus((prev) => {
2606
2620
  const next = { ...prev };
2621
+ const metadata = workbenchRuntimeState;
2607
2622
  for (const n of def.nodes) {
2608
2623
  const cur = next[n.nodeId] ?? (next[n.nodeId] = {});
2624
+ const nodeMeta = metadata.nodes[n.nodeId];
2609
2625
  const updates = {};
2610
2626
  if (cur.invalidated === undefined) {
2611
- updates.invalidated = true;
2627
+ updates.invalidated = computeInvalidatedFromMetadata(nodeMeta);
2612
2628
  }
2613
- // Ensure activeRunIds is always initialized as an array
2614
2629
  if (cur.activeRunIds === undefined) {
2615
2630
  updates.activeRunIds = [];
2616
2631
  }
2632
+ if (cur.activeRuns === undefined) {
2633
+ updates.activeRuns = 0;
2634
+ }
2635
+ if (nodeMeta?.lastErrorSummary && cur.lastError === undefined) {
2636
+ updates.lastError = nodeMeta.lastErrorSummary;
2637
+ }
2617
2638
  if (Object.keys(updates).length > 0) {
2618
2639
  next[n.nodeId] = { ...cur, ...updates };
2619
2640
  }
2620
2641
  }
2621
2642
  return next;
2622
2643
  });
2623
- }, [def]);
2644
+ }, [def, wb]);
2624
2645
  // Auto layout (simple layered layout)
2625
2646
  const runAutoLayout = useCallback(() => {
2626
2647
  const cur = wb.export();
@@ -2687,10 +2708,31 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2687
2708
  }
2688
2709
  curX += maxWidth + H_GAP;
2689
2710
  }
2690
- wb.setPositions(pos, { commit: true });
2711
+ wb.setPositions(pos, { commit: true, reason: "auto-layout" });
2691
2712
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2692
2713
  const updateEdgeType = useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2693
2714
  const triggerExternal = useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
2715
+ // Helper to save runtime metadata to extData.runtime and workbench state
2716
+ const saveRuntimeMetadata = useCallback(async () => {
2717
+ try {
2718
+ const current = wb.getRuntimeState() ?? { nodes: {} };
2719
+ const metadata = { nodes: { ...current.nodes } };
2720
+ // Clean up metadata for nodes that no longer exist
2721
+ const nodeIds = new Set(def.nodes.map((n) => n.nodeId));
2722
+ for (const nodeId of Object.keys(metadata.nodes)) {
2723
+ if (!nodeIds.has(nodeId)) {
2724
+ delete metadata.nodes[nodeId];
2725
+ }
2726
+ }
2727
+ // Save cleaned metadata to workbench state
2728
+ wb.setRuntimeState(metadata);
2729
+ // Save to extData.runtime via runner (no snapshotFull)
2730
+ await runner.setExtData?.({ runtime: metadata });
2731
+ }
2732
+ catch (err) {
2733
+ console.warn("[WorkbenchContext] Failed to save runtime metadata:", err);
2734
+ }
2735
+ }, [wb, def, runner]);
2694
2736
  // Subscribe to runner/workbench events
2695
2737
  useEffect(() => {
2696
2738
  const add = (source, type) => (payload) => setEvents((prev) => {
@@ -2733,9 +2775,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2733
2775
  wb.refreshValidation();
2734
2776
  };
2735
2777
  const offRunnerValue = runner.on("value", (e) => {
2778
+ const now = Date.now();
2736
2779
  if (e?.io === "input") {
2737
- const nodeId = e?.nodeId;
2738
- const handle = e?.handle;
2780
+ const nodeId = e.nodeId;
2781
+ const handle = e.handle;
2782
+ // Track input timestamp in workbench runtime state
2783
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2784
+ ...nodeMeta,
2785
+ lastInputAt: {
2786
+ ...(nodeMeta.lastInputAt ?? {}),
2787
+ [handle]: now,
2788
+ },
2789
+ }));
2739
2790
  setNodeStatus((s) => ({
2740
2791
  ...s,
2741
2792
  [nodeId]: { ...s[nodeId], invalidated: true },
@@ -2743,6 +2794,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2743
2794
  // Clear validation errors for this input when a valid value is set
2744
2795
  setInputValidationErrors((prev) => prev.filter((err) => !(err.nodeId === nodeId && err.handle === handle)));
2745
2796
  }
2797
+ else if (e?.io === "output") {
2798
+ const nodeId = e.nodeId;
2799
+ const handle = e.handle;
2800
+ // Track output timestamp in workbench runtime state
2801
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2802
+ ...nodeMeta,
2803
+ lastOutputAt: {
2804
+ ...(nodeMeta.lastOutputAt ?? {}),
2805
+ [handle]: now,
2806
+ },
2807
+ }));
2808
+ }
2746
2809
  return add("runner", "value")(e);
2747
2810
  });
2748
2811
  const offRunnerError = runner.on("error", (e) => {
@@ -2761,6 +2824,24 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2761
2824
  else if (nodeError.kind === "node-run" && nodeError.nodeId) {
2762
2825
  const nodeId = nodeError.nodeId;
2763
2826
  const runId = nodeError.runId;
2827
+ const now = Date.now();
2828
+ // Track error timestamp and summary in workbench runtime state
2829
+ const err = nodeError.err;
2830
+ let errorSummary;
2831
+ if (err && typeof err === "object") {
2832
+ const message = err.message || String(err);
2833
+ const code = err.code || err.statusCode;
2834
+ errorSummary = {
2835
+ message: typeof message === "string" ? message : String(message),
2836
+ code: typeof code === "number" ? code : undefined,
2837
+ };
2838
+ }
2839
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2840
+ ...nodeMeta,
2841
+ lastErrorAt: now,
2842
+ lastRunAt: now,
2843
+ ...(errorSummary ? { lastErrorSummary: errorSummary } : {}),
2844
+ }));
2764
2845
  setNodeStatus((s) => ({
2765
2846
  ...s,
2766
2847
  [nodeId]: {
@@ -2815,6 +2896,30 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2815
2896
  // If resolvedHandles are included in the event, use them directly (more efficient)
2816
2897
  if (e?.resolvedHandles && Object.keys(e.resolvedHandles).length > 0) {
2817
2898
  applyResolvedHandles(e.resolvedHandles);
2899
+ // Mark nodes whose handles changed as invalid
2900
+ const affectedNodeIds = Object.keys(e.resolvedHandles);
2901
+ if (affectedNodeIds.length > 0) {
2902
+ setNodeStatus((prev) => {
2903
+ const next = { ...prev };
2904
+ for (const id of affectedNodeIds) {
2905
+ const cur = next[id] ?? (next[id] = { activeRuns: 0, activeRunIds: [] });
2906
+ next[id] = { ...cur, invalidated: true };
2907
+ }
2908
+ return next;
2909
+ });
2910
+ }
2911
+ }
2912
+ // For broader invalidations (e.g. registry-changed, graph-updated), mark all nodes invalid
2913
+ if (e?.reason === "registry-changed" || e?.reason === "graph-updated") {
2914
+ setNodeStatus((prev) => {
2915
+ const next = { ...prev };
2916
+ for (const n of def.nodes) {
2917
+ const cur = next[n.nodeId] ??
2918
+ (next[n.nodeId] = { activeRuns: 0, activeRunIds: [] });
2919
+ next[n.nodeId] = { ...cur, invalidated: true };
2920
+ }
2921
+ return next;
2922
+ });
2818
2923
  }
2819
2924
  return add("runner", "invalidate")(e);
2820
2925
  });
@@ -2824,6 +2929,12 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2824
2929
  if (s.kind === "node-start") {
2825
2930
  const id = s.nodeId;
2826
2931
  const runId = s.runId;
2932
+ const now = Date.now();
2933
+ // Track run timestamp in workbench runtime state
2934
+ wb.updateNodeRuntimeMetadata(id, (nodeMeta) => ({
2935
+ ...nodeMeta,
2936
+ lastRunAt: now,
2937
+ }));
2827
2938
  // Validate runId is a non-empty string
2828
2939
  const isValidRunId = runId && typeof runId === "string" && runId.length > 0;
2829
2940
  if (!isValidRunId) {
@@ -2846,7 +2957,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2846
2957
  };
2847
2958
  });
2848
2959
  // Start fallback animation window
2849
- setFallbackStarts((prev) => ({ ...prev, [id]: Date.now() }));
2960
+ setFallbackStarts((prev) => ({ ...prev, [id]: now }));
2850
2961
  }
2851
2962
  else if (s.kind === "node-progress") {
2852
2963
  const id = s.nodeId;
@@ -2861,8 +2972,21 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2861
2972
  else if (s.kind === "node-done") {
2862
2973
  const id = s.nodeId;
2863
2974
  const runId = s.runId;
2975
+ const now = Date.now();
2864
2976
  // Validate runId is a non-empty string
2865
2977
  const isValidRunId = runId && typeof runId === "string" && runId.length > 0;
2978
+ const hadError = !!(isValidRunId && errorRunsRef.current[id]?.[runId]);
2979
+ // Track success timestamp if no error in workbench runtime state
2980
+ if (!hadError) {
2981
+ wb.updateNodeRuntimeMetadata(id, (nodeMeta) => {
2982
+ const updated = { ...nodeMeta, lastSuccessAt: now };
2983
+ // Clear error summary on success
2984
+ if (updated.lastErrorSummary) {
2985
+ delete updated.lastErrorSummary;
2986
+ }
2987
+ return updated;
2988
+ });
2989
+ }
2866
2990
  setNodeStatus((prev) => {
2867
2991
  const current = prev[id]?.activeRuns ?? 0;
2868
2992
  const currentRunIds = prev[id]?.activeRunIds ?? [];
@@ -2874,7 +2998,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2874
2998
  const nextRunIds = isValidRunId
2875
2999
  ? currentRunIds.filter((rid) => rid !== runId)
2876
3000
  : currentRunIds;
2877
- const hadError = !!(isValidRunId && errorRunsRef.current[id]?.[runId]);
2878
3001
  const keepProgress = hadError || nextActive > 0;
2879
3002
  // Clear error flag for this runId
2880
3003
  if (isValidRunId && errorRunsRef.current[id]?.[runId]) {
@@ -2943,11 +3066,40 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2943
3066
  }
2944
3067
  });
2945
3068
  const offWbGraphChangedForUpdate = wb.on("graphChanged", async (event) => {
3069
+ // Build detailed reason from change type
3070
+ let reason = "graph-changed";
3071
+ if (event.change) {
3072
+ const changeType = event.change.type;
3073
+ if (changeType === "addNode") {
3074
+ reason = "add-node";
3075
+ }
3076
+ else if (changeType === "removeNode") {
3077
+ reason = "remove-node";
3078
+ }
3079
+ else if (changeType === "connect") {
3080
+ reason = "connect-edge";
3081
+ }
3082
+ else if (changeType === "disconnect") {
3083
+ reason = "disconnect-edge";
3084
+ }
3085
+ else if (changeType === "updateParams") {
3086
+ reason = "update-node-params";
3087
+ }
3088
+ else if (changeType === "updateEdgeType") {
3089
+ reason = "update-edge-type";
3090
+ }
3091
+ }
2946
3092
  if (!runner.isRunning()) {
2947
- // If runner not running, commit immediately (no update needed)
2948
- await runner.commit().catch((err) => {
2949
- console.error("[WorkbenchContext] Error committing:", err);
2950
- });
3093
+ if (event.commit) {
3094
+ await saveRuntimeMetadata();
3095
+ const history = await runner.commit(reason).catch((err) => {
3096
+ console.error("[WorkbenchContext] Error committing:", err);
3097
+ return undefined;
3098
+ });
3099
+ if (history) {
3100
+ wb.setHistory(history);
3101
+ }
3102
+ }
2951
3103
  return;
2952
3104
  }
2953
3105
  try {
@@ -2972,10 +3124,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2972
3124
  else {
2973
3125
  await runner.update(event.def, { dry: event.dry });
2974
3126
  }
2975
- // Wait for update to complete, then commit
2976
- await runner.commit().catch((err) => {
2977
- console.error("[WorkbenchContext] Error committing after update:", err);
2978
- });
3127
+ if (event.commit) {
3128
+ await saveRuntimeMetadata();
3129
+ const history = await runner
3130
+ .commit(event.reason ?? reason)
3131
+ .catch((err) => {
3132
+ console.error("[WorkbenchContext] Error committing after update:", err);
3133
+ return undefined;
3134
+ });
3135
+ if (history) {
3136
+ wb.setHistory(history);
3137
+ }
3138
+ }
2979
3139
  }
2980
3140
  catch (err) {
2981
3141
  console.error("[WorkbenchContext] Error updating graph:", err);
@@ -2985,17 +3145,49 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2985
3145
  const offWbSelectionChanged = wb.on("selectionChanged", async (sel) => {
2986
3146
  setSelectedNodeId(sel.nodes?.[0]);
2987
3147
  setSelectedEdgeId(sel.edges?.[0]);
2988
- // Commit on selection change
2989
- await runner.commit().catch((err) => {
2990
- console.error("[WorkbenchContext] Error committing selection change:", err);
2991
- });
3148
+ if (sel.commit) {
3149
+ await saveRuntimeMetadata();
3150
+ const history = await runner
3151
+ .commit(sel.reason ?? "selection")
3152
+ .catch((err) => {
3153
+ console.error("[WorkbenchContext] Error committing selection change:", err);
3154
+ return undefined;
3155
+ });
3156
+ if (history) {
3157
+ wb.setHistory(history);
3158
+ }
3159
+ }
2992
3160
  });
2993
3161
  const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
2994
3162
  // Only commit if commit flag is true (e.g., drag end, not during dragging)
2995
3163
  if (event.commit) {
2996
- await runner.commit().catch((err) => {
3164
+ // Build detailed reason from change type
3165
+ let reason = "ui-changed";
3166
+ if (event.change) {
3167
+ const changeType = event.change.type;
3168
+ if (changeType === "moveNode") {
3169
+ reason = "move-node";
3170
+ }
3171
+ else if (changeType === "moveNodes") {
3172
+ reason = "move-nodes";
3173
+ }
3174
+ else if (changeType === "selection") {
3175
+ reason = "selection";
3176
+ }
3177
+ else if (changeType === "viewport") {
3178
+ reason = "viewport";
3179
+ }
3180
+ }
3181
+ await saveRuntimeMetadata();
3182
+ const history = await runner
3183
+ .commit(event.reason ?? reason)
3184
+ .catch((err) => {
2997
3185
  console.error("[WorkbenchContext] Error committing UI changes:", err);
3186
+ return undefined;
2998
3187
  });
3188
+ if (history) {
3189
+ wb.setHistory(history);
3190
+ }
2999
3191
  }
3000
3192
  });
3001
3193
  const offWbError = wb.on("error", add("workbench", "error"));
@@ -3016,11 +3208,24 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3016
3208
  console.error("Failed to handle registry changed event");
3017
3209
  }
3018
3210
  });
3019
- // Handle transport disconnect: reset runtime status when connection is lost
3211
+ // Handle transport changes: reset runtime status when connection is lost
3020
3212
  const offRunnerTransport = runner.on("transport", (t) => {
3021
3213
  if (t.state === "disconnected") {
3022
3214
  console.info("[WorkbenchContext] Transport disconnected, resetting node status");
3023
- setNodeStatus({});
3215
+ // Reinitialize node status with invalidated=true for all nodes
3216
+ setNodeStatus(() => {
3217
+ const next = {};
3218
+ const metadata = wb.getRuntimeState() ?? { nodes: {} };
3219
+ for (const n of def.nodes) {
3220
+ const nodeMeta = metadata.nodes[n.nodeId];
3221
+ next[n.nodeId] = {
3222
+ activeRuns: 0,
3223
+ activeRunIds: [],
3224
+ invalidated: computeInvalidatedFromMetadata(nodeMeta),
3225
+ };
3226
+ }
3227
+ return next;
3228
+ });
3024
3229
  setEdgeStatus({});
3025
3230
  setFallbackStarts({});
3026
3231
  errorRunsRef.current = {};
@@ -3209,6 +3414,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3209
3414
  try {
3210
3415
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
3211
3416
  const raw = outputsMap?.[nodeId]?.[handleId];
3417
+ let newNodeId;
3212
3418
  if (!typeId || raw === undefined)
3213
3419
  return;
3214
3420
  const unwrap = (v) => isTypedOutput(v) ? getTypedOutputValue(v) : v;
@@ -3234,23 +3440,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3234
3440
  const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3235
3441
  const inType = getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3236
3442
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3237
- wb.addNode({
3443
+ newNodeId = wb.addNode({
3238
3444
  typeId: singleTarget.nodeTypeId,
3239
3445
  position: { x: pos.x + 180, y: pos.y },
3240
3446
  }, { inputs: { [singleTarget.inputHandle]: coerced } });
3241
- return;
3242
3447
  }
3243
- if (isArray && arrTarget) {
3448
+ else if (isArray && arrTarget) {
3244
3449
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3245
3450
  const inType = getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3246
3451
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3247
- wb.addNode({
3452
+ newNodeId = wb.addNode({
3248
3453
  typeId: arrTarget.nodeTypeId,
3249
3454
  position: { x: pos.x + 180, y: pos.y },
3250
3455
  }, { inputs: { [arrTarget.inputHandle]: coerced } });
3251
- return;
3252
3456
  }
3253
- if (isArray && elemTarget) {
3457
+ else if (isArray && elemTarget) {
3254
3458
  const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
3255
3459
  const inType = getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
3256
3460
  const src = unwrap(raw);
@@ -3262,19 +3466,21 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3262
3466
  for (let idx = 0; idx < coercedItems.length; idx++) {
3263
3467
  const col = idx % COLS;
3264
3468
  const row = Math.floor(idx / COLS);
3265
- wb.addNode({
3469
+ newNodeId = wb.addNode({
3266
3470
  typeId: elemTarget.nodeTypeId,
3267
3471
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3268
3472
  }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3269
3473
  }
3270
- return;
3474
+ }
3475
+ if (newNodeId) {
3476
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "bake" });
3271
3477
  }
3272
3478
  }
3273
3479
  catch { }
3274
3480
  };
3275
3481
  return {
3276
3482
  onDelete: () => {
3277
- wb.removeNode(nodeId);
3483
+ wb.removeNode(nodeId, { commit: true });
3278
3484
  onClose();
3279
3485
  },
3280
3486
  onDuplicate: async () => {
@@ -3299,10 +3505,7 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3299
3505
  dry: true,
3300
3506
  });
3301
3507
  // Select the newly duplicated node
3302
- wb.setSelection({
3303
- nodes: [newNodeId],
3304
- edges: [],
3305
- });
3508
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate" });
3306
3509
  onClose();
3307
3510
  },
3308
3511
  onDuplicateWithEdges: async () => {
@@ -3335,10 +3538,9 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3335
3538
  }, { dry: true });
3336
3539
  }
3337
3540
  // Select the newly duplicated node and edges
3338
- wb.setSelection({
3339
- nodes: [newNodeId],
3340
- edges: [],
3341
- });
3541
+ if (newNodeId) {
3542
+ wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate-with-edges" });
3543
+ }
3342
3544
  onClose();
3343
3545
  },
3344
3546
  onRunPull: async () => {
@@ -3412,7 +3614,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3412
3614
  onClose();
3413
3615
  },
3414
3616
  onDelete: () => {
3415
- wb.deleteSelection();
3617
+ wb.deleteSelection({ commit: true, reason: "delete-selection" });
3416
3618
  onClose();
3417
3619
  },
3418
3620
  onClose,
@@ -3421,7 +3623,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3421
3623
  /**
3422
3624
  * Creates base default context menu handlers.
3423
3625
  */
3424
- function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData) {
3626
+ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData, history) {
3425
3627
  // Wrap paste handler to clear storage after paste
3426
3628
  const wrappedOnPaste = onPaste && getCopiedData && clearCopiedData
3427
3629
  ? (position) => {
@@ -3429,16 +3631,17 @@ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, g
3429
3631
  clearCopiedData();
3430
3632
  }
3431
3633
  : onPaste;
3432
- // Function to check if paste data exists (called dynamically when menu opens)
3433
3634
  const hasPasteData = getCopiedData ? () => !!getCopiedData() : undefined;
3635
+ const canUndo = history ? history.undoCount > 0 : undefined;
3636
+ const canRedo = history ? history.redoCount > 0 : undefined;
3434
3637
  return {
3435
3638
  onAddNode,
3436
3639
  onPaste: wrappedOnPaste,
3437
3640
  hasPasteData,
3438
3641
  onUndo: runner ? () => runner.undo().then(() => onClose()) : undefined,
3439
3642
  onRedo: runner ? () => runner.redo().then(() => onClose()) : undefined,
3440
- canUndo: runner ? () => runner.canUndo().catch(() => false) : undefined,
3441
- canRedo: runner ? () => runner.canRedo().catch(() => false) : undefined,
3643
+ canUndo,
3644
+ canRedo,
3442
3645
  onClose,
3443
3646
  };
3444
3647
  }
@@ -3962,6 +4165,16 @@ function DefaultNodeContent({ data, isConnectable, }) {
3962
4165
  } })] }));
3963
4166
  }
3964
4167
 
4168
+ // Helper to format shortcut for current platform
4169
+ function formatShortcut(shortcut) {
4170
+ const isMac = typeof navigator !== "undefined" &&
4171
+ navigator.userAgent.toLowerCase().includes("mac");
4172
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4173
+ }
4174
+ function ContextMenuButton({ label, onClick, disabled = false, shortcut, enableKeyboardShortcuts = true, }) {
4175
+ return (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: onClick, disabled: disabled, children: [jsx("span", { children: label }), enableKeyboardShortcuts && shortcut && (jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(shortcut) }))] }));
4176
+ }
4177
+
3965
4178
  function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enableKeyboardShortcuts = true, keyboardShortcuts = {
3966
4179
  undo: "⌘/Ctrl + Z",
3967
4180
  redo: "⌘/Ctrl + Shift + Z",
@@ -3969,41 +4182,24 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
3969
4182
  }, }) {
3970
4183
  const rf = useReactFlow();
3971
4184
  const [query, setQuery] = useState("");
3972
- const [canUndo, setCanUndo] = useState(false);
3973
- const [canRedo, setCanRedo] = useState(false);
3974
4185
  const [hasPasteData, setHasPasteData] = useState(false);
3975
4186
  const q = query.trim().toLowerCase();
3976
4187
  const filteredIds = q
3977
4188
  ? nodeIds.filter((id) => id.toLowerCase().includes(q))
3978
4189
  : nodeIds;
3979
- // Check undo/redo availability and paste data when menu opens
4190
+ const canUndo = handlers.canUndo ?? false;
4191
+ const canRedo = handlers.canRedo ?? false;
3980
4192
  useEffect(() => {
3981
4193
  if (!open)
3982
4194
  return;
3983
- let cancelled = false;
3984
- const checkAvailability = async () => {
3985
- if (handlers.canUndo) {
3986
- const result = await handlers.canUndo();
3987
- if (!cancelled)
3988
- setCanUndo(result);
3989
- }
3990
- if (handlers.canRedo) {
3991
- const result = await handlers.canRedo();
3992
- if (!cancelled)
3993
- setCanRedo(result);
3994
- }
3995
- // Check paste data dynamically
3996
- if (handlers.hasPasteData) {
3997
- const result = handlers.hasPasteData();
3998
- if (!cancelled)
3999
- setHasPasteData(result);
4000
- }
4001
- };
4002
- checkAvailability();
4003
- return () => {
4004
- cancelled = true;
4005
- };
4006
- }, [open, handlers.canUndo, handlers.canRedo, handlers.hasPasteData]);
4195
+ if (handlers.hasPasteData) {
4196
+ const result = handlers.hasPasteData();
4197
+ setHasPasteData(result);
4198
+ }
4199
+ else {
4200
+ setHasPasteData(false);
4201
+ }
4202
+ }, [open, handlers.hasPasteData]);
4007
4203
  const root = { __children: {} };
4008
4204
  for (const id of filteredIds) {
4009
4205
  const parts = id.split(".");
@@ -4068,12 +4264,6 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4068
4264
  handlers.onPaste(p);
4069
4265
  handlers.onClose();
4070
4266
  };
4071
- // Helper to format shortcut for current platform
4072
- const formatShortcut = (shortcut) => {
4073
- const isMac = typeof navigator !== "undefined" &&
4074
- navigator.userAgent.toLowerCase().includes("mac");
4075
- return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4076
- };
4077
4267
  const renderTree = (tree, path = []) => {
4078
4268
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
4079
4269
  return (jsx("div", { children: entries.map(([key, child]) => {
@@ -4091,13 +4281,17 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4091
4281
  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) => {
4092
4282
  e.preventDefault();
4093
4283
  e.stopPropagation();
4094
- }, 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 &&
4284
+ }, children: [hasPasteData && handlers.onPaste && (jsx(ContextMenuButton, { label: "Paste", onClick: handlePaste, shortcut: keyboardShortcuts.paste, enableKeyboardShortcuts: enableKeyboardShortcuts })), (handlers.onUndo || handlers.onRedo) && (jsxs(Fragment, { children: [hasPasteData && handlers.onPaste && (jsx("div", { className: "h-px bg-gray-200 my-1" })), handlers.onUndo && (jsx(ContextMenuButton, { label: "Undo", onClick: handlers.onUndo, disabled: !canUndo, shortcut: keyboardShortcuts.undo, enableKeyboardShortcuts: enableKeyboardShortcuts })), handlers.onRedo && (jsx(ContextMenuButton, { label: "Redo", onClick: handlers.onRedo, disabled: !canRedo, shortcut: keyboardShortcuts.redo, enableKeyboardShortcuts: enableKeyboardShortcuts }))] })), hasPasteData &&
4095
4285
  handlers.onPaste &&
4096
4286
  !handlers.onUndo &&
4097
- !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 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" })) })] }));
4287
+ !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" })) })] }));
4098
4288
  }
4099
4289
 
4100
- function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, }) {
4290
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, enableKeyboardShortcuts = true, keyboardShortcuts = {
4291
+ copy: "⌘/Ctrl + C",
4292
+ duplicate: "⌘/Ctrl + D",
4293
+ delete: "Delete",
4294
+ }, }) {
4101
4295
  const ref = useRef(null);
4102
4296
  // outside click + ESC
4103
4297
  useEffect(() => {
@@ -4135,7 +4329,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4135
4329
  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) => {
4136
4330
  e.preventDefault();
4137
4331
  e.stopPropagation();
4138
- }, 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)))] }))] }));
4332
+ }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx(ContextMenuButton, { label: "Duplicate", onClick: handlers.onDuplicate, shortcut: keyboardShortcuts.duplicate, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx("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(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" }), bakeableOutputs.length > 0 && (jsxs(Fragment, { children: [jsx("div", { className: "h-px bg-gray-200 my-1" }), jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4139
4333
  }
4140
4334
 
4141
4335
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -4168,12 +4362,6 @@ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcu
4168
4362
  if (open)
4169
4363
  ref.current?.focus();
4170
4364
  }, [open]);
4171
- // Helper to format shortcut for current platform
4172
- const formatShortcut = (shortcut) => {
4173
- const isMac = typeof navigator !== "undefined" &&
4174
- navigator.userAgent.toLowerCase().includes("mac");
4175
- return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4176
- };
4177
4365
  if (!open || !clientPos)
4178
4366
  return null;
4179
4367
  // Clamp menu position to viewport
@@ -4185,7 +4373,7 @@ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcu
4185
4373
  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) => {
4186
4374
  e.preventDefault();
4187
4375
  e.stopPropagation();
4188
- }, 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) }))] })] }));
4376
+ }, children: [jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts })] }));
4189
4377
  }
4190
4378
 
4191
4379
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
@@ -4193,7 +4381,6 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4193
4381
  const nodeValidation = validationByNode;
4194
4382
  const edgeValidation = validationByEdge.errors;
4195
4383
  const [registryVersion, setRegistryVersion] = useState(0);
4196
- // Keep stable references for nodes/edges to avoid unnecessary updates
4197
4384
  const prevNodesRef = useRef([]);
4198
4385
  const prevEdgesRef = useRef([]);
4199
4386
  function retainStabilityById(prev, next, isSame) {
@@ -4523,7 +4710,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4523
4710
  setNodeMenuOpen(false);
4524
4711
  setSelectionMenuOpen(false);
4525
4712
  };
4526
- const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs }), [wb]);
4713
+ const addNodeAt = useCallback(async (typeId, opts) => wb.addNode({ typeId, position: opts.position }, { inputs: opts.inputs, commit: true }), [wb]);
4527
4714
  const onCloseMenu = useCallback(() => {
4528
4715
  setMenuOpen(false);
4529
4716
  }, []);
@@ -4548,16 +4735,13 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4548
4735
  get: () => wb.getCopiedData(),
4549
4736
  set: (data) => wb.setCopiedData(data),
4550
4737
  };
4551
- const baseHandlers = createDefaultContextMenuHandlers(addNodeAt, onCloseMenu,
4552
- // Paste handler - checks storage dynamically when called
4553
- // Only provide handler if storage has data or might have data (for dynamic checking)
4554
- (position) => {
4738
+ const baseHandlers = createDefaultContextMenuHandlers(addNodeAt, onCloseMenu, (position) => {
4555
4739
  const data = storage.get();
4556
4740
  if (!data)
4557
4741
  return;
4558
- wb.pasteCopiedData(data, position);
4742
+ wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4559
4743
  onCloseMenu();
4560
- }, runner, () => storage.get(), () => storage.set(null));
4744
+ }, runner, () => storage.get(), () => storage.set(null), wb.getHistory());
4561
4745
  if (overrides?.getDefaultContextMenuHandlers) {
4562
4746
  return overrides.getDefaultContextMenuHandlers(wb, baseHandlers);
4563
4747
  }
@@ -4576,9 +4760,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4576
4760
  }, runner);
4577
4761
  if (overrides?.getSelectionContextMenuHandlers) {
4578
4762
  const selection = wb.getSelection();
4579
- return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, {
4580
- getDefaultNodeSize: overrides.getDefaultNodeSize,
4581
- });
4763
+ return overrides.getSelectionContextMenuHandlers(wb, selection, baseHandlers, { getDefaultNodeSize: overrides.getDefaultNodeSize });
4582
4764
  }
4583
4765
  return baseHandlers;
4584
4766
  }, [wb, runner, overrides, onCloseSelectionMenu]);
@@ -4624,6 +4806,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4624
4806
  redo: "⌘/Ctrl + Shift + Z",
4625
4807
  copy: "⌘/Ctrl + C",
4626
4808
  paste: "⌘/Ctrl + V",
4809
+ duplicate: "⌘/Ctrl + D",
4627
4810
  delete: "Delete",
4628
4811
  };
4629
4812
  // Keyboard shortcut handler
@@ -4648,9 +4831,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4648
4831
  e.preventDefault();
4649
4832
  if (runner &&
4650
4833
  "onUndo" in defaultContextMenuHandlers &&
4651
- defaultContextMenuHandlers.onUndo) {
4652
- const canUndo = await runner.canUndo().catch(() => false);
4653
- if (canUndo) {
4834
+ defaultContextMenuHandlers.onUndo &&
4835
+ defaultContextMenuHandlers.canUndo) {
4836
+ if (defaultContextMenuHandlers.canUndo) {
4654
4837
  defaultContextMenuHandlers.onUndo();
4655
4838
  }
4656
4839
  }
@@ -4661,9 +4844,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4661
4844
  e.preventDefault();
4662
4845
  if (runner &&
4663
4846
  "onRedo" in defaultContextMenuHandlers &&
4664
- defaultContextMenuHandlers.onRedo) {
4665
- const canRedo = await runner.canRedo().catch(() => false);
4666
- if (canRedo) {
4847
+ defaultContextMenuHandlers.onRedo &&
4848
+ defaultContextMenuHandlers.canRedo) {
4849
+ if (defaultContextMenuHandlers.canRedo) {
4667
4850
  defaultContextMenuHandlers.onRedo();
4668
4851
  }
4669
4852
  }
@@ -4674,12 +4857,26 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4674
4857
  const selection = wb.getSelection();
4675
4858
  if (selection.nodes.length > 0 || selection.edges.length > 0) {
4676
4859
  e.preventDefault();
4677
- if (selectionContextMenuHandlers.onCopy) {
4860
+ // If single node selected, use node context menu handler; otherwise use selection handler
4861
+ if (selection.nodes.length === 1 && nodeContextMenuHandlers?.onCopy) {
4862
+ nodeContextMenuHandlers.onCopy();
4863
+ }
4864
+ else if (selectionContextMenuHandlers.onCopy) {
4678
4865
  selectionContextMenuHandlers.onCopy();
4679
4866
  }
4680
4867
  }
4681
4868
  return;
4682
4869
  }
4870
+ // Duplicate: Cmd/Ctrl + D
4871
+ if (modKey && key === "d" && !e.shiftKey && !e.altKey) {
4872
+ const selection = wb.getSelection();
4873
+ if (selection.nodes.length === 1 &&
4874
+ nodeContextMenuHandlers?.onDuplicate) {
4875
+ e.preventDefault();
4876
+ nodeContextMenuHandlers.onDuplicate();
4877
+ }
4878
+ return;
4879
+ }
4683
4880
  // Paste: Cmd/Ctrl + V
4684
4881
  if (modKey && key === "v" && !e.shiftKey && !e.altKey) {
4685
4882
  e.preventDefault();
@@ -4709,6 +4906,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4709
4906
  runner,
4710
4907
  defaultContextMenuHandlers,
4711
4908
  selectionContextMenuHandlers,
4909
+ nodeContextMenuHandlers,
4712
4910
  rfInstanceRef,
4713
4911
  ]);
4714
4912
  // Get custom renderers from UI extension registry (reactive to uiVersion changes)
@@ -4725,7 +4923,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4725
4923
  const onMoveEnd = useCallback(() => {
4726
4924
  if (rfInstanceRef.current) {
4727
4925
  const viewport = rfInstanceRef.current.getViewport();
4728
- wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { commit: true });
4926
+ wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom });
4729
4927
  }
4730
4928
  }, [wb]);
4731
4929
  const viewportRef = useRef(null);
@@ -4761,7 +4959,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4761
4959
  ? { enableKeyboardShortcuts, keyboardShortcuts }
4762
4960
  : {}) })) : (jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })), !!nodeAtMenu &&
4763
4961
  nodeContextMenuHandlers &&
4764
- (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, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts }))] }) }) }));
4962
+ (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs, ...(enableKeyboardShortcuts !== false
4963
+ ? { enableKeyboardShortcuts, keyboardShortcuts }
4964
+ : {}) })) : (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 }))] }) }) }));
4765
4965
  });
4766
4966
 
4767
4967
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {