@bian-womp/spark-workbench 0.2.70 → 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 (71) hide show
  1. package/lib/cjs/index.cjs +272 -128
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +10 -9
  4. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/index.d.ts +1 -1
  6. package/lib/cjs/src/index.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  8. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +1 -1
  9. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  10. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/context-menu/ContextMenuButton.d.ts +9 -0
  12. package/lib/cjs/src/misc/context-menu/ContextMenuButton.d.ts.map +1 -0
  13. package/lib/cjs/src/misc/{context → context-menu}/ContextMenuHandlers.d.ts +2 -2
  14. package/lib/cjs/src/misc/context-menu/ContextMenuHandlers.d.ts.map +1 -0
  15. package/lib/cjs/src/misc/{context → context-menu}/ContextMenuHelpers.d.ts +2 -1
  16. package/lib/cjs/src/misc/context-menu/ContextMenuHelpers.d.ts.map +1 -0
  17. package/lib/cjs/src/misc/{DefaultContextMenu.d.ts → context-menu/DefaultContextMenu.d.ts} +1 -1
  18. package/lib/cjs/src/misc/context-menu/DefaultContextMenu.d.ts.map +1 -0
  19. package/lib/{esm/src/misc → cjs/src/misc/context-menu}/NodeContextMenu.d.ts +1 -1
  20. package/lib/cjs/src/misc/context-menu/NodeContextMenu.d.ts.map +1 -0
  21. package/lib/cjs/src/misc/{SelectionContextMenu.d.ts → context-menu/SelectionContextMenu.d.ts} +1 -1
  22. package/lib/cjs/src/misc/context-menu/SelectionContextMenu.d.ts.map +1 -0
  23. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  24. package/lib/cjs/src/misc/load.d.ts.map +1 -1
  25. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +2 -4
  26. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  27. package/lib/cjs/src/runtime/IGraphRunner.d.ts +3 -4
  28. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  29. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +2 -4
  30. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  31. package/lib/esm/index.js +272 -128
  32. package/lib/esm/index.js.map +1 -1
  33. package/lib/esm/src/core/InMemoryWorkbench.d.ts +10 -9
  34. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  35. package/lib/esm/src/index.d.ts +1 -1
  36. package/lib/esm/src/index.d.ts.map +1 -1
  37. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  38. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +1 -1
  39. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  40. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  41. package/lib/esm/src/misc/context-menu/ContextMenuButton.d.ts +9 -0
  42. package/lib/esm/src/misc/context-menu/ContextMenuButton.d.ts.map +1 -0
  43. package/lib/esm/src/misc/{context → context-menu}/ContextMenuHandlers.d.ts +2 -2
  44. package/lib/esm/src/misc/context-menu/ContextMenuHandlers.d.ts.map +1 -0
  45. package/lib/esm/src/misc/{context → context-menu}/ContextMenuHelpers.d.ts +2 -1
  46. package/lib/esm/src/misc/context-menu/ContextMenuHelpers.d.ts.map +1 -0
  47. package/lib/esm/src/misc/{DefaultContextMenu.d.ts → context-menu/DefaultContextMenu.d.ts} +1 -1
  48. package/lib/esm/src/misc/context-menu/DefaultContextMenu.d.ts.map +1 -0
  49. package/lib/{cjs/src/misc → esm/src/misc/context-menu}/NodeContextMenu.d.ts +1 -1
  50. package/lib/esm/src/misc/context-menu/NodeContextMenu.d.ts.map +1 -0
  51. package/lib/esm/src/misc/{SelectionContextMenu.d.ts → context-menu/SelectionContextMenu.d.ts} +1 -1
  52. package/lib/esm/src/misc/context-menu/SelectionContextMenu.d.ts.map +1 -0
  53. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  54. package/lib/esm/src/misc/load.d.ts.map +1 -1
  55. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +2 -4
  56. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  57. package/lib/esm/src/runtime/IGraphRunner.d.ts +3 -4
  58. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  59. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +2 -4
  60. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  61. package/package.json +4 -4
  62. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +0 -1
  63. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +0 -1
  64. package/lib/cjs/src/misc/SelectionContextMenu.d.ts.map +0 -1
  65. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +0 -1
  66. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +0 -1
  67. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +0 -1
  68. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +0 -1
  69. package/lib/esm/src/misc/SelectionContextMenu.d.ts.map +0 -1
  70. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +0 -1
  71. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +0 -1
package/lib/cjs/index.cjs CHANGED
@@ -125,13 +125,15 @@ class InMemoryWorkbench extends AbstractWorkbench {
125
125
  constructor() {
126
126
  super(...arguments);
127
127
  this.def = { nodes: [], edges: [] };
128
- this.positions = {};
129
128
  this.listeners = new Map();
129
+ this.positions = {};
130
130
  this.selection = {
131
131
  nodes: [],
132
132
  edges: [],
133
133
  };
134
134
  this.viewport = null;
135
+ this.runtimeState = null;
136
+ this.historyState = undefined;
135
137
  this.copiedData = null;
136
138
  }
137
139
  setRegistry(registry) {
@@ -277,14 +279,6 @@ class InMemoryWorkbench extends AbstractWorkbench {
277
279
  });
278
280
  }
279
281
  // Position and selection APIs for React Flow bridge
280
- setPosition(nodeId, pos, options) {
281
- this.positions[nodeId] = pos;
282
- this.emit("graphUiChanged", {
283
- def: this.def,
284
- change: { type: "moveNode", nodeId, pos },
285
- ...options,
286
- });
287
- }
288
282
  setPositions(map, options) {
289
283
  this.positions = { ...map };
290
284
  this.emit("graphUiChanged", {
@@ -297,6 +291,8 @@ class InMemoryWorkbench extends AbstractWorkbench {
297
291
  return { ...this.positions };
298
292
  }
299
293
  setSelection(sel, options) {
294
+ if (lod.isEqual(this.selection, sel))
295
+ return;
300
296
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
301
297
  this.emit("selectionChanged", this.selection);
302
298
  this.emit("graphUiChanged", {
@@ -374,6 +370,27 @@ class InMemoryWorkbench extends AbstractWorkbench {
374
370
  this.viewport = { ...ui.viewport };
375
371
  }
376
372
  }
373
+ getRuntimeState() {
374
+ return this.runtimeState ? { ...this.runtimeState } : null;
375
+ }
376
+ setRuntimeState(runtime) {
377
+ this.runtimeState = runtime ? { ...runtime } : null;
378
+ }
379
+ getHistory() {
380
+ return this.historyState;
381
+ }
382
+ setHistory(history) {
383
+ this.historyState = history;
384
+ }
385
+ getNodeRuntimeMetadata(nodeId) {
386
+ return this.runtimeState?.nodes[nodeId];
387
+ }
388
+ updateNodeRuntimeMetadata(nodeId, updater) {
389
+ const current = this.runtimeState ?? { nodes: {} };
390
+ const nodeMeta = current.nodes[nodeId] ?? {};
391
+ const updated = updater({ ...nodeMeta });
392
+ this.runtimeState = { nodes: { ...current.nodes, [nodeId]: updated } };
393
+ }
377
394
  on(event, handler) {
378
395
  if (!this.listeners.has(event))
379
396
  this.listeners.set(event, new Set());
@@ -679,14 +696,10 @@ class AbstractGraphRunner {
679
696
  async redo() {
680
697
  return false;
681
698
  }
682
- async canUndo() {
683
- return false;
684
- }
685
- async canRedo() {
686
- return false;
687
- }
688
699
  // Optional commit support
689
- async commit(_reason) { }
700
+ async commit(_reason) {
701
+ return undefined;
702
+ }
690
703
  }
691
704
 
692
705
  // Counter for generating readable runner IDs
@@ -1436,7 +1449,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1436
1449
  async commit(reason) {
1437
1450
  const client = await this.ensureClient();
1438
1451
  try {
1439
- await client.commit(reason);
1452
+ const history = await client.commit(reason);
1453
+ return history;
1440
1454
  }
1441
1455
  catch (err) {
1442
1456
  console.error("[RemoteGraphRunner] Error committing:", err);
@@ -1461,24 +1475,6 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1461
1475
  return false;
1462
1476
  }
1463
1477
  }
1464
- async canUndo() {
1465
- const client = await this.ensureClient();
1466
- try {
1467
- return await client.canUndo();
1468
- }
1469
- catch {
1470
- return false;
1471
- }
1472
- }
1473
- async canRedo() {
1474
- const client = await this.ensureClient();
1475
- try {
1476
- return await client.canRedo();
1477
- }
1478
- catch {
1479
- return false;
1480
- }
1481
- }
1482
1478
  async snapshotFull() {
1483
1479
  const client = await this.ensureClient();
1484
1480
  try {
@@ -1920,9 +1916,13 @@ function useWorkbenchBridge(wb) {
1920
1916
  }, [wb]);
1921
1917
  const onNodesChange = React.useCallback((changes) => {
1922
1918
  // Apply position updates continuously, but mark commit only on drag end
1919
+ const positions = {};
1920
+ let commit = false;
1923
1921
  changes.forEach((c) => {
1924
1922
  if (c.type === "position" && c.position) {
1925
- wb.setPosition(c.id, c.position, { commit: !c.dragging });
1923
+ positions[c.id] = c.position;
1924
+ if (!c.dragging)
1925
+ commit = true;
1926
1926
  }
1927
1927
  });
1928
1928
  // Derive next node selection from change set
@@ -1953,10 +1953,10 @@ function useWorkbenchBridge(wb) {
1953
1953
  }
1954
1954
  }
1955
1955
  if (selectionChanged) {
1956
- wb.setSelection({
1957
- nodes: Array.from(nextNodeIds),
1958
- edges: current.edges,
1959
- });
1956
+ wb.setSelection({ nodes: Array.from(nextNodeIds), edges: current.edges }, { commit: !(Object.keys(positions).length && commit) });
1957
+ }
1958
+ if (Object.keys(positions).length > 0) {
1959
+ wb.setPositions(positions, { commit });
1960
1960
  }
1961
1961
  }, [wb]);
1962
1962
  const onEdgesDelete = React.useCallback((edges) => edges.forEach((e, idx) => wb.disconnect(e.id, { commit: idx === edges.length - 1 })), [wb]);
@@ -1988,10 +1988,7 @@ function useWorkbenchBridge(wb) {
1988
1988
  }
1989
1989
  }
1990
1990
  if (selectionChanged) {
1991
- wb.setSelection({
1992
- nodes: current.nodes,
1993
- edges: Array.from(nextEdgeIds),
1994
- });
1991
+ wb.setSelection({ nodes: current.nodes, edges: Array.from(nextEdgeIds) }, { commit: true });
1995
1992
  }
1996
1993
  }, [wb]);
1997
1994
  const onNodesDelete = React.useCallback((nodes) => {
@@ -2439,6 +2436,7 @@ async function download(wb, runner) {
2439
2436
  try {
2440
2437
  const def = wb.export();
2441
2438
  const uiState = wb.getUIState();
2439
+ const runtimeState = wb.getRuntimeState();
2442
2440
  let snapshot;
2443
2441
  if (runner.isRunning()) {
2444
2442
  const fullSnapshot = await runner.snapshotFull();
@@ -2448,6 +2446,7 @@ async function download(wb, runner) {
2448
2446
  extData: {
2449
2447
  ...(fullSnapshot.extData || {}),
2450
2448
  ui: uiState,
2449
+ runtime: runtimeState || undefined,
2451
2450
  },
2452
2451
  };
2453
2452
  }
@@ -2458,7 +2457,7 @@ async function download(wb, runner) {
2458
2457
  inputs,
2459
2458
  outputs: {},
2460
2459
  environment: {},
2461
- extData: { ui: uiState },
2460
+ extData: { ui: uiState, runtime: runtimeState || undefined },
2462
2461
  };
2463
2462
  }
2464
2463
  downloadJSON(snapshot, `spark-snapshot-${generateTimestamp()}.json`);
@@ -2484,6 +2483,9 @@ async function upload(parsed, wb, runner) {
2484
2483
  if (extData.ui && typeof extData.ui === "object") {
2485
2484
  wb.setUIState(extData.ui);
2486
2485
  }
2486
+ if (extData.runtime && typeof extData.runtime === "object") {
2487
+ wb.setRuntimeState(extData.runtime);
2488
+ }
2487
2489
  if (runner.isRunning()) {
2488
2490
  await runner.applySnapshotFull({
2489
2491
  def,
@@ -2511,6 +2513,18 @@ function useWorkbenchContext() {
2511
2513
  return ctx;
2512
2514
  }
2513
2515
 
2516
+ // Helper to compute invalidated status from runtime metadata
2517
+ function computeInvalidatedFromMetadata(metadata) {
2518
+ if (!metadata)
2519
+ return true;
2520
+ const { lastSuccessAt, lastInputAt, lastRunAt } = metadata;
2521
+ if (!lastSuccessAt && !lastRunAt)
2522
+ return true;
2523
+ if (!lastInputAt || Object.keys(lastInputAt).length === 0)
2524
+ return false;
2525
+ const maxInputTime = Math.max(...Object.values(lastInputAt));
2526
+ return maxInputTime > (lastSuccessAt ?? lastRunAt ?? 0);
2527
+ }
2514
2528
  function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVersion, children, }) {
2515
2529
  const [nodeStatus, setNodeStatus] = React.useState({});
2516
2530
  const [edgeStatus, setEdgeStatus] = React.useState({});
@@ -2601,27 +2615,35 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2601
2615
  }
2602
2616
  return out;
2603
2617
  }, [def, outputsMap, registry]);
2604
- // Initialize nodes as invalidated by default until first successful run
2618
+ // Initialize nodes and derive invalidated status from persisted metadata
2605
2619
  React.useEffect(() => {
2620
+ const workbenchRuntimeState = wb.getRuntimeState() ?? { nodes: {} };
2606
2621
  setNodeStatus((prev) => {
2607
2622
  const next = { ...prev };
2623
+ const metadata = workbenchRuntimeState;
2608
2624
  for (const n of def.nodes) {
2609
2625
  const cur = next[n.nodeId] ?? (next[n.nodeId] = {});
2626
+ const nodeMeta = metadata.nodes[n.nodeId];
2610
2627
  const updates = {};
2611
2628
  if (cur.invalidated === undefined) {
2612
- updates.invalidated = true;
2629
+ updates.invalidated = computeInvalidatedFromMetadata(nodeMeta);
2613
2630
  }
2614
- // Ensure activeRunIds is always initialized as an array
2615
2631
  if (cur.activeRunIds === undefined) {
2616
2632
  updates.activeRunIds = [];
2617
2633
  }
2634
+ if (cur.activeRuns === undefined) {
2635
+ updates.activeRuns = 0;
2636
+ }
2637
+ if (nodeMeta?.lastErrorSummary && cur.lastError === undefined) {
2638
+ updates.lastError = nodeMeta.lastErrorSummary;
2639
+ }
2618
2640
  if (Object.keys(updates).length > 0) {
2619
2641
  next[n.nodeId] = { ...cur, ...updates };
2620
2642
  }
2621
2643
  }
2622
2644
  return next;
2623
2645
  });
2624
- }, [def]);
2646
+ }, [def, wb]);
2625
2647
  // Auto layout (simple layered layout)
2626
2648
  const runAutoLayout = React.useCallback(() => {
2627
2649
  const cur = wb.export();
@@ -2692,6 +2714,27 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2692
2714
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2693
2715
  const updateEdgeType = React.useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2694
2716
  const triggerExternal = React.useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
2717
+ // Helper to save runtime metadata to extData.runtime and workbench state
2718
+ const saveRuntimeMetadata = React.useCallback(async () => {
2719
+ try {
2720
+ const current = wb.getRuntimeState() ?? { nodes: {} };
2721
+ const metadata = { nodes: { ...current.nodes } };
2722
+ // Clean up metadata for nodes that no longer exist
2723
+ const nodeIds = new Set(def.nodes.map((n) => n.nodeId));
2724
+ for (const nodeId of Object.keys(metadata.nodes)) {
2725
+ if (!nodeIds.has(nodeId)) {
2726
+ delete metadata.nodes[nodeId];
2727
+ }
2728
+ }
2729
+ // Save cleaned metadata to workbench state
2730
+ wb.setRuntimeState(metadata);
2731
+ // Save to extData.runtime via runner (no snapshotFull)
2732
+ await runner.setExtData?.({ runtime: metadata });
2733
+ }
2734
+ catch (err) {
2735
+ console.warn("[WorkbenchContext] Failed to save runtime metadata:", err);
2736
+ }
2737
+ }, [wb, def, runner]);
2695
2738
  // Subscribe to runner/workbench events
2696
2739
  React.useEffect(() => {
2697
2740
  const add = (source, type) => (payload) => setEvents((prev) => {
@@ -2734,9 +2777,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2734
2777
  wb.refreshValidation();
2735
2778
  };
2736
2779
  const offRunnerValue = runner.on("value", (e) => {
2780
+ const now = Date.now();
2737
2781
  if (e?.io === "input") {
2738
- const nodeId = e?.nodeId;
2739
- const handle = e?.handle;
2782
+ const nodeId = e.nodeId;
2783
+ const handle = e.handle;
2784
+ // Track input timestamp in workbench runtime state
2785
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2786
+ ...nodeMeta,
2787
+ lastInputAt: {
2788
+ ...(nodeMeta.lastInputAt ?? {}),
2789
+ [handle]: now,
2790
+ },
2791
+ }));
2740
2792
  setNodeStatus((s) => ({
2741
2793
  ...s,
2742
2794
  [nodeId]: { ...s[nodeId], invalidated: true },
@@ -2744,6 +2796,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2744
2796
  // Clear validation errors for this input when a valid value is set
2745
2797
  setInputValidationErrors((prev) => prev.filter((err) => !(err.nodeId === nodeId && err.handle === handle)));
2746
2798
  }
2799
+ else if (e?.io === "output") {
2800
+ const nodeId = e.nodeId;
2801
+ const handle = e.handle;
2802
+ // Track output timestamp in workbench runtime state
2803
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2804
+ ...nodeMeta,
2805
+ lastOutputAt: {
2806
+ ...(nodeMeta.lastOutputAt ?? {}),
2807
+ [handle]: now,
2808
+ },
2809
+ }));
2810
+ }
2747
2811
  return add("runner", "value")(e);
2748
2812
  });
2749
2813
  const offRunnerError = runner.on("error", (e) => {
@@ -2762,6 +2826,24 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2762
2826
  else if (nodeError.kind === "node-run" && nodeError.nodeId) {
2763
2827
  const nodeId = nodeError.nodeId;
2764
2828
  const runId = nodeError.runId;
2829
+ const now = Date.now();
2830
+ // Track error timestamp and summary in workbench runtime state
2831
+ const err = nodeError.err;
2832
+ let errorSummary;
2833
+ if (err && typeof err === "object") {
2834
+ const message = err.message || String(err);
2835
+ const code = err.code || err.statusCode;
2836
+ errorSummary = {
2837
+ message: typeof message === "string" ? message : String(message),
2838
+ code: typeof code === "number" ? code : undefined,
2839
+ };
2840
+ }
2841
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2842
+ ...nodeMeta,
2843
+ lastErrorAt: now,
2844
+ lastRunAt: now,
2845
+ ...(errorSummary ? { lastErrorSummary: errorSummary } : {}),
2846
+ }));
2765
2847
  setNodeStatus((s) => ({
2766
2848
  ...s,
2767
2849
  [nodeId]: {
@@ -2816,6 +2898,30 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2816
2898
  // If resolvedHandles are included in the event, use them directly (more efficient)
2817
2899
  if (e?.resolvedHandles && Object.keys(e.resolvedHandles).length > 0) {
2818
2900
  applyResolvedHandles(e.resolvedHandles);
2901
+ // Mark nodes whose handles changed as invalid
2902
+ const affectedNodeIds = Object.keys(e.resolvedHandles);
2903
+ if (affectedNodeIds.length > 0) {
2904
+ setNodeStatus((prev) => {
2905
+ const next = { ...prev };
2906
+ for (const id of affectedNodeIds) {
2907
+ const cur = next[id] ?? (next[id] = { activeRuns: 0, activeRunIds: [] });
2908
+ next[id] = { ...cur, invalidated: true };
2909
+ }
2910
+ return next;
2911
+ });
2912
+ }
2913
+ }
2914
+ // For broader invalidations (e.g. registry-changed, graph-updated), mark all nodes invalid
2915
+ if (e?.reason === "registry-changed" || e?.reason === "graph-updated") {
2916
+ setNodeStatus((prev) => {
2917
+ const next = { ...prev };
2918
+ for (const n of def.nodes) {
2919
+ const cur = next[n.nodeId] ??
2920
+ (next[n.nodeId] = { activeRuns: 0, activeRunIds: [] });
2921
+ next[n.nodeId] = { ...cur, invalidated: true };
2922
+ }
2923
+ return next;
2924
+ });
2819
2925
  }
2820
2926
  return add("runner", "invalidate")(e);
2821
2927
  });
@@ -2825,6 +2931,12 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2825
2931
  if (s.kind === "node-start") {
2826
2932
  const id = s.nodeId;
2827
2933
  const runId = s.runId;
2934
+ const now = Date.now();
2935
+ // Track run timestamp in workbench runtime state
2936
+ wb.updateNodeRuntimeMetadata(id, (nodeMeta) => ({
2937
+ ...nodeMeta,
2938
+ lastRunAt: now,
2939
+ }));
2828
2940
  // Validate runId is a non-empty string
2829
2941
  const isValidRunId = runId && typeof runId === "string" && runId.length > 0;
2830
2942
  if (!isValidRunId) {
@@ -2847,7 +2959,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2847
2959
  };
2848
2960
  });
2849
2961
  // Start fallback animation window
2850
- setFallbackStarts((prev) => ({ ...prev, [id]: Date.now() }));
2962
+ setFallbackStarts((prev) => ({ ...prev, [id]: now }));
2851
2963
  }
2852
2964
  else if (s.kind === "node-progress") {
2853
2965
  const id = s.nodeId;
@@ -2862,8 +2974,21 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2862
2974
  else if (s.kind === "node-done") {
2863
2975
  const id = s.nodeId;
2864
2976
  const runId = s.runId;
2977
+ const now = Date.now();
2865
2978
  // Validate runId is a non-empty string
2866
2979
  const isValidRunId = runId && typeof runId === "string" && runId.length > 0;
2980
+ const hadError = !!(isValidRunId && errorRunsRef.current[id]?.[runId]);
2981
+ // Track success timestamp if no error in workbench runtime state
2982
+ if (!hadError) {
2983
+ wb.updateNodeRuntimeMetadata(id, (nodeMeta) => {
2984
+ const updated = { ...nodeMeta, lastSuccessAt: now };
2985
+ // Clear error summary on success
2986
+ if (updated.lastErrorSummary) {
2987
+ delete updated.lastErrorSummary;
2988
+ }
2989
+ return updated;
2990
+ });
2991
+ }
2867
2992
  setNodeStatus((prev) => {
2868
2993
  const current = prev[id]?.activeRuns ?? 0;
2869
2994
  const currentRunIds = prev[id]?.activeRunIds ?? [];
@@ -2875,7 +3000,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2875
3000
  const nextRunIds = isValidRunId
2876
3001
  ? currentRunIds.filter((rid) => rid !== runId)
2877
3002
  : currentRunIds;
2878
- const hadError = !!(isValidRunId && errorRunsRef.current[id]?.[runId]);
2879
3003
  const keepProgress = hadError || nextActive > 0;
2880
3004
  // Clear error flag for this runId
2881
3005
  if (isValidRunId && errorRunsRef.current[id]?.[runId]) {
@@ -2969,10 +3093,14 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2969
3093
  }
2970
3094
  if (!runner.isRunning()) {
2971
3095
  if (event.commit) {
2972
- // If runner not running, commit immediately (no update needed)
2973
- await runner.commit(reason).catch((err) => {
3096
+ await saveRuntimeMetadata();
3097
+ const history = await runner.commit(reason).catch((err) => {
2974
3098
  console.error("[WorkbenchContext] Error committing:", err);
3099
+ return undefined;
2975
3100
  });
3101
+ if (history) {
3102
+ wb.setHistory(history);
3103
+ }
2976
3104
  }
2977
3105
  return;
2978
3106
  }
@@ -2999,10 +3127,16 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2999
3127
  await runner.update(event.def, { dry: event.dry });
3000
3128
  }
3001
3129
  if (event.commit) {
3002
- // Wait for update to complete, then commit
3003
- await runner.commit(event.reason ?? reason).catch((err) => {
3130
+ await saveRuntimeMetadata();
3131
+ const history = await runner
3132
+ .commit(event.reason ?? reason)
3133
+ .catch((err) => {
3004
3134
  console.error("[WorkbenchContext] Error committing after update:", err);
3135
+ return undefined;
3005
3136
  });
3137
+ if (history) {
3138
+ wb.setHistory(history);
3139
+ }
3006
3140
  }
3007
3141
  }
3008
3142
  catch (err) {
@@ -3014,23 +3148,48 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3014
3148
  setSelectedNodeId(sel.nodes?.[0]);
3015
3149
  setSelectedEdgeId(sel.edges?.[0]);
3016
3150
  if (sel.commit) {
3017
- // Commit on selection change
3018
- await runner.commit(sel.reason ?? "selection").catch((err) => {
3151
+ await saveRuntimeMetadata();
3152
+ const history = await runner
3153
+ .commit(sel.reason ?? "selection")
3154
+ .catch((err) => {
3019
3155
  console.error("[WorkbenchContext] Error committing selection change:", err);
3156
+ return undefined;
3020
3157
  });
3158
+ if (history) {
3159
+ wb.setHistory(history);
3160
+ }
3021
3161
  }
3022
3162
  });
3023
3163
  const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
3024
3164
  // Only commit if commit flag is true (e.g., drag end, not during dragging)
3025
3165
  if (event.commit) {
3166
+ // Build detailed reason from change type
3167
+ let reason = "ui-changed";
3026
3168
  if (event.change) {
3027
- event.change.type;
3169
+ const changeType = event.change.type;
3170
+ if (changeType === "moveNode") {
3171
+ reason = "move-node";
3172
+ }
3173
+ else if (changeType === "moveNodes") {
3174
+ reason = "move-nodes";
3175
+ }
3176
+ else if (changeType === "selection") {
3177
+ reason = "selection";
3178
+ }
3179
+ else if (changeType === "viewport") {
3180
+ reason = "viewport";
3181
+ }
3028
3182
  }
3029
- await runner
3030
- .commit(event.reason ?? "ui-changed")
3183
+ await saveRuntimeMetadata();
3184
+ const history = await runner
3185
+ .commit(event.reason ?? reason)
3031
3186
  .catch((err) => {
3032
3187
  console.error("[WorkbenchContext] Error committing UI changes:", err);
3188
+ return undefined;
3033
3189
  });
3190
+ if (history) {
3191
+ wb.setHistory(history);
3192
+ }
3034
3193
  }
3035
3194
  });
3036
3195
  const offWbError = wb.on("error", add("workbench", "error"));
@@ -3051,11 +3210,24 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3051
3210
  console.error("Failed to handle registry changed event");
3052
3211
  }
3053
3212
  });
3054
- // Handle transport disconnect: reset runtime status when connection is lost
3213
+ // Handle transport changes: reset runtime status when connection is lost
3055
3214
  const offRunnerTransport = runner.on("transport", (t) => {
3056
3215
  if (t.state === "disconnected") {
3057
3216
  console.info("[WorkbenchContext] Transport disconnected, resetting node status");
3058
- setNodeStatus({});
3217
+ // Reinitialize node status with invalidated=true for all nodes
3218
+ setNodeStatus(() => {
3219
+ const next = {};
3220
+ const metadata = wb.getRuntimeState() ?? { nodes: {} };
3221
+ for (const n of def.nodes) {
3222
+ const nodeMeta = metadata.nodes[n.nodeId];
3223
+ next[n.nodeId] = {
3224
+ activeRuns: 0,
3225
+ activeRunIds: [],
3226
+ invalidated: computeInvalidatedFromMetadata(nodeMeta),
3227
+ };
3228
+ }
3229
+ return next;
3230
+ });
3059
3231
  setEdgeStatus({});
3060
3232
  setFallbackStarts({});
3061
3233
  errorRunsRef.current = {};
@@ -3453,7 +3625,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3453
3625
  /**
3454
3626
  * Creates base default context menu handlers.
3455
3627
  */
3456
- function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData) {
3628
+ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData, history) {
3457
3629
  // Wrap paste handler to clear storage after paste
3458
3630
  const wrappedOnPaste = onPaste && getCopiedData && clearCopiedData
3459
3631
  ? (position) => {
@@ -3461,16 +3633,17 @@ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, g
3461
3633
  clearCopiedData();
3462
3634
  }
3463
3635
  : onPaste;
3464
- // Function to check if paste data exists (called dynamically when menu opens)
3465
3636
  const hasPasteData = getCopiedData ? () => !!getCopiedData() : undefined;
3637
+ const canUndo = history ? history.undoCount > 0 : undefined;
3638
+ const canRedo = history ? history.redoCount > 0 : undefined;
3466
3639
  return {
3467
3640
  onAddNode,
3468
3641
  onPaste: wrappedOnPaste,
3469
3642
  hasPasteData,
3470
3643
  onUndo: runner ? () => runner.undo().then(() => onClose()) : undefined,
3471
3644
  onRedo: runner ? () => runner.redo().then(() => onClose()) : undefined,
3472
- canUndo: runner ? () => runner.canUndo().catch(() => false) : undefined,
3473
- canRedo: runner ? () => runner.canRedo().catch(() => false) : undefined,
3645
+ canUndo,
3646
+ canRedo,
3474
3647
  onClose,
3475
3648
  };
3476
3649
  }
@@ -3994,6 +4167,16 @@ function DefaultNodeContent({ data, isConnectable, }) {
3994
4167
  } })] }));
3995
4168
  }
3996
4169
 
4170
+ // Helper to format shortcut for current platform
4171
+ function formatShortcut(shortcut) {
4172
+ const isMac = typeof navigator !== "undefined" &&
4173
+ navigator.userAgent.toLowerCase().includes("mac");
4174
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4175
+ }
4176
+ function ContextMenuButton({ label, onClick, disabled = false, shortcut, enableKeyboardShortcuts = true, }) {
4177
+ return (jsxRuntime.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: [jsxRuntime.jsx("span", { children: label }), enableKeyboardShortcuts && shortcut && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(shortcut) }))] }));
4178
+ }
4179
+
3997
4180
  function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enableKeyboardShortcuts = true, keyboardShortcuts = {
3998
4181
  undo: "⌘/Ctrl + Z",
3999
4182
  redo: "⌘/Ctrl + Shift + Z",
@@ -4001,41 +4184,24 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4001
4184
  }, }) {
4002
4185
  const rf = react.useReactFlow();
4003
4186
  const [query, setQuery] = React.useState("");
4004
- const [canUndo, setCanUndo] = React.useState(false);
4005
- const [canRedo, setCanRedo] = React.useState(false);
4006
4187
  const [hasPasteData, setHasPasteData] = React.useState(false);
4007
4188
  const q = query.trim().toLowerCase();
4008
4189
  const filteredIds = q
4009
4190
  ? nodeIds.filter((id) => id.toLowerCase().includes(q))
4010
4191
  : nodeIds;
4011
- // Check undo/redo availability and paste data when menu opens
4192
+ const canUndo = handlers.canUndo ?? false;
4193
+ const canRedo = handlers.canRedo ?? false;
4012
4194
  React.useEffect(() => {
4013
4195
  if (!open)
4014
4196
  return;
4015
- let cancelled = false;
4016
- const checkAvailability = async () => {
4017
- if (handlers.canUndo) {
4018
- const result = await handlers.canUndo();
4019
- if (!cancelled)
4020
- setCanUndo(result);
4021
- }
4022
- if (handlers.canRedo) {
4023
- const result = await handlers.canRedo();
4024
- if (!cancelled)
4025
- setCanRedo(result);
4026
- }
4027
- // Check paste data dynamically
4028
- if (handlers.hasPasteData) {
4029
- const result = handlers.hasPasteData();
4030
- if (!cancelled)
4031
- setHasPasteData(result);
4032
- }
4033
- };
4034
- checkAvailability();
4035
- return () => {
4036
- cancelled = true;
4037
- };
4038
- }, [open, handlers.canUndo, handlers.canRedo, handlers.hasPasteData]);
4197
+ if (handlers.hasPasteData) {
4198
+ const result = handlers.hasPasteData();
4199
+ setHasPasteData(result);
4200
+ }
4201
+ else {
4202
+ setHasPasteData(false);
4203
+ }
4204
+ }, [open, handlers.hasPasteData]);
4039
4205
  const root = { __children: {} };
4040
4206
  for (const id of filteredIds) {
4041
4207
  const parts = id.split(".");
@@ -4100,12 +4266,6 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4100
4266
  handlers.onPaste(p);
4101
4267
  handlers.onClose();
4102
4268
  };
4103
- // Helper to format shortcut for current platform
4104
- const formatShortcut = (shortcut) => {
4105
- const isMac = typeof navigator !== "undefined" &&
4106
- navigator.userAgent.toLowerCase().includes("mac");
4107
- return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4108
- };
4109
4269
  const renderTree = (tree, path = []) => {
4110
4270
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
4111
4271
  return (jsxRuntime.jsx("div", { children: entries.map(([key, child]) => {
@@ -4123,7 +4283,7 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4123
4283
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
4124
4284
  e.preventDefault();
4125
4285
  e.stopPropagation();
4126
- }, children: [hasPasteData && handlers.onPaste && (jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Paste" }), enableKeyboardShortcuts && keyboardShortcuts.paste && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.paste) }))] })), (handlers.onUndo || handlers.onRedo) && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [hasPasteData && handlers.onPaste && (jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" })), handlers.onUndo && (jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Undo" }), enableKeyboardShortcuts && keyboardShortcuts.undo && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.undo) }))] })), handlers.onRedo && (jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Redo" }), enableKeyboardShortcuts && keyboardShortcuts.redo && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.redo) }))] }))] })), hasPasteData &&
4286
+ }, children: [hasPasteData && handlers.onPaste && (jsxRuntime.jsx(ContextMenuButton, { label: "Paste", onClick: handlePaste, shortcut: keyboardShortcuts.paste, enableKeyboardShortcuts: enableKeyboardShortcuts })), (handlers.onUndo || handlers.onRedo) && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [hasPasteData && handlers.onPaste && (jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" })), handlers.onUndo && (jsxRuntime.jsx(ContextMenuButton, { label: "Undo", onClick: handlers.onUndo, disabled: !canUndo, shortcut: keyboardShortcuts.undo, enableKeyboardShortcuts: enableKeyboardShortcuts })), handlers.onRedo && (jsxRuntime.jsx(ContextMenuButton, { label: "Redo", onClick: handlers.onRedo, disabled: !canRedo, shortcut: keyboardShortcuts.redo, enableKeyboardShortcuts: enableKeyboardShortcuts }))] })), hasPasteData &&
4127
4287
  handlers.onPaste &&
4128
4288
  !handlers.onUndo &&
4129
4289
  !handlers.onRedo && jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxRuntime.jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsxRuntime.jsx("div", { className: "px-2 pb-1", children: jsxRuntime.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() }) }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsxRuntime.jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
@@ -4160,12 +4320,6 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4160
4320
  if (open)
4161
4321
  ref.current?.focus();
4162
4322
  }, [open]);
4163
- // Helper to format shortcut for current platform
4164
- const formatShortcut = (shortcut) => {
4165
- const isMac = typeof navigator !== "undefined" &&
4166
- navigator.userAgent.toLowerCase().includes("mac");
4167
- return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4168
- };
4169
4323
  if (!open || !clientPos || !nodeId)
4170
4324
  return null;
4171
4325
  // clamp
@@ -4177,7 +4331,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4177
4331
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
4178
4332
  e.preventDefault();
4179
4333
  e.stopPropagation();
4180
- }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] }), jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Duplicate" }), enableKeyboardShortcuts && keyboardShortcuts.duplicate && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.duplicate) }))] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), jsxRuntime.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 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4334
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx(ContextMenuButton, { label: "Duplicate", onClick: handlers.onDuplicate, shortcut: keyboardShortcuts.duplicate, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicateWithEdges, children: "Duplicate with edges" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.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 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h)))] }))] }));
4181
4335
  }
4182
4336
 
4183
4337
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -4210,12 +4364,6 @@ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcu
4210
4364
  if (open)
4211
4365
  ref.current?.focus();
4212
4366
  }, [open]);
4213
- // Helper to format shortcut for current platform
4214
- const formatShortcut = (shortcut) => {
4215
- const isMac = typeof navigator !== "undefined" &&
4216
- navigator.userAgent.toLowerCase().includes("mac");
4217
- return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4218
- };
4219
4367
  if (!open || !clientPos)
4220
4368
  return null;
4221
4369
  // Clamp menu position to viewport
@@ -4227,7 +4375,7 @@ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcu
4227
4375
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700 select-none", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
4228
4376
  e.preventDefault();
4229
4377
  e.stopPropagation();
4230
- }, children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Copy" }), enableKeyboardShortcuts && keyboardShortcuts.copy && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.copy) }))] }), jsxRuntime.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: [jsxRuntime.jsx("span", { children: "Delete" }), enableKeyboardShortcuts && keyboardShortcuts.delete && (jsxRuntime.jsx("span", { className: "text-gray-400 text-xs ml-4", children: formatShortcut(keyboardShortcuts.delete) }))] })] }));
4378
+ }, children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Selection" }), jsxRuntime.jsx(ContextMenuButton, { label: "Copy", onClick: handlers.onCopy, shortcut: keyboardShortcuts.copy, enableKeyboardShortcuts: enableKeyboardShortcuts }), jsxRuntime.jsx(ContextMenuButton, { label: "Delete", onClick: handlers.onDelete, shortcut: keyboardShortcuts.delete, enableKeyboardShortcuts: enableKeyboardShortcuts })] }));
4231
4379
  }
4232
4380
 
4233
4381
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
@@ -4235,7 +4383,6 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4235
4383
  const nodeValidation = validationByNode;
4236
4384
  const edgeValidation = validationByEdge.errors;
4237
4385
  const [registryVersion, setRegistryVersion] = React.useState(0);
4238
- // Keep stable references for nodes/edges to avoid unnecessary updates
4239
4386
  const prevNodesRef = React.useRef([]);
4240
4387
  const prevEdgesRef = React.useRef([]);
4241
4388
  function retainStabilityById(prev, next, isSame) {
@@ -4590,16 +4737,13 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4590
4737
  get: () => wb.getCopiedData(),
4591
4738
  set: (data) => wb.setCopiedData(data),
4592
4739
  };
4593
- const baseHandlers = createDefaultContextMenuHandlers(addNodeAt, onCloseMenu,
4594
- // Paste handler - checks storage dynamically when called
4595
- // Only provide handler if storage has data or might have data (for dynamic checking)
4596
- (position) => {
4740
+ const baseHandlers = createDefaultContextMenuHandlers(addNodeAt, onCloseMenu, (position) => {
4597
4741
  const data = storage.get();
4598
4742
  if (!data)
4599
4743
  return;
4600
4744
  wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4601
4745
  onCloseMenu();
4602
- }, runner, () => storage.get(), () => storage.set(null));
4746
+ }, runner, () => storage.get(), () => storage.set(null), wb.getHistory());
4603
4747
  if (overrides?.getDefaultContextMenuHandlers) {
4604
4748
  return overrides.getDefaultContextMenuHandlers(wb, baseHandlers);
4605
4749
  }
@@ -4689,9 +4833,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4689
4833
  e.preventDefault();
4690
4834
  if (runner &&
4691
4835
  "onUndo" in defaultContextMenuHandlers &&
4692
- defaultContextMenuHandlers.onUndo) {
4693
- const canUndo = await runner.canUndo().catch(() => false);
4694
- if (canUndo) {
4836
+ defaultContextMenuHandlers.onUndo &&
4837
+ defaultContextMenuHandlers.canUndo) {
4838
+ if (defaultContextMenuHandlers.canUndo) {
4695
4839
  defaultContextMenuHandlers.onUndo();
4696
4840
  }
4697
4841
  }
@@ -4702,9 +4846,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4702
4846
  e.preventDefault();
4703
4847
  if (runner &&
4704
4848
  "onRedo" in defaultContextMenuHandlers &&
4705
- defaultContextMenuHandlers.onRedo) {
4706
- const canRedo = await runner.canRedo().catch(() => false);
4707
- if (canRedo) {
4849
+ defaultContextMenuHandlers.onRedo &&
4850
+ defaultContextMenuHandlers.canRedo) {
4851
+ if (defaultContextMenuHandlers.canRedo) {
4708
4852
  defaultContextMenuHandlers.onRedo();
4709
4853
  }
4710
4854
  }