@bian-womp/spark-workbench 0.2.70 → 0.2.72

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 (75) hide show
  1. package/lib/cjs/index.cjs +281 -129
  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/core/contracts.d.ts +5 -2
  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 +1 -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 +2 -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/{esm/src/misc → cjs/src/misc/context-menu}/NodeContextMenu.d.ts +1 -1
  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 +281 -129
  34. package/lib/esm/index.js.map +1 -1
  35. package/lib/esm/src/core/InMemoryWorkbench.d.ts +10 -9
  36. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  37. package/lib/esm/src/core/contracts.d.ts +5 -2
  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 +1 -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 +2 -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/{cjs/src/misc → esm/src/misc/context-menu}/NodeContextMenu.d.ts +1 -1
  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.map +0 -1
  68. package/lib/cjs/src/misc/SelectionContextMenu.d.ts.map +0 -1
  69. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +0 -1
  70. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +0 -1
  71. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +0 -1
  72. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +0 -1
  73. package/lib/esm/src/misc/SelectionContextMenu.d.ts.map +0 -1
  74. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +0 -1
  75. 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,28 @@ 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
+ this.emit("historyChanged", { history });
385
+ }
386
+ getNodeRuntimeMetadata(nodeId) {
387
+ return this.runtimeState?.nodes[nodeId];
388
+ }
389
+ updateNodeRuntimeMetadata(nodeId, updater) {
390
+ const current = this.runtimeState ?? { nodes: {} };
391
+ const nodeMeta = current.nodes[nodeId] ?? {};
392
+ const updated = updater({ ...nodeMeta });
393
+ this.runtimeState = { nodes: { ...current.nodes, [nodeId]: updated } };
394
+ }
377
395
  on(event, handler) {
378
396
  if (!this.listeners.has(event))
379
397
  this.listeners.set(event, new Set());
@@ -679,14 +697,10 @@ class AbstractGraphRunner {
679
697
  async redo() {
680
698
  return false;
681
699
  }
682
- async canUndo() {
683
- return false;
684
- }
685
- async canRedo() {
686
- return false;
687
- }
688
700
  // Optional commit support
689
- async commit(_reason) { }
701
+ async commit(_reason) {
702
+ return undefined;
703
+ }
690
704
  }
691
705
 
692
706
  // Counter for generating readable runner IDs
@@ -1436,7 +1450,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1436
1450
  async commit(reason) {
1437
1451
  const client = await this.ensureClient();
1438
1452
  try {
1439
- await client.commit(reason);
1453
+ const history = await client.commit(reason);
1454
+ return history;
1440
1455
  }
1441
1456
  catch (err) {
1442
1457
  console.error("[RemoteGraphRunner] Error committing:", err);
@@ -1461,24 +1476,6 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1461
1476
  return false;
1462
1477
  }
1463
1478
  }
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
1479
  async snapshotFull() {
1483
1480
  const client = await this.ensureClient();
1484
1481
  try {
@@ -1920,9 +1917,13 @@ function useWorkbenchBridge(wb) {
1920
1917
  }, [wb]);
1921
1918
  const onNodesChange = React.useCallback((changes) => {
1922
1919
  // Apply position updates continuously, but mark commit only on drag end
1920
+ const positions = {};
1921
+ let commit = false;
1923
1922
  changes.forEach((c) => {
1924
1923
  if (c.type === "position" && c.position) {
1925
- wb.setPosition(c.id, c.position, { commit: !c.dragging });
1924
+ positions[c.id] = c.position;
1925
+ if (!c.dragging)
1926
+ commit = true;
1926
1927
  }
1927
1928
  });
1928
1929
  // Derive next node selection from change set
@@ -1953,10 +1954,10 @@ function useWorkbenchBridge(wb) {
1953
1954
  }
1954
1955
  }
1955
1956
  if (selectionChanged) {
1956
- wb.setSelection({
1957
- nodes: Array.from(nextNodeIds),
1958
- edges: current.edges,
1959
- });
1957
+ wb.setSelection({ nodes: Array.from(nextNodeIds), edges: current.edges }, { commit: !(Object.keys(positions).length && commit) });
1958
+ }
1959
+ if (Object.keys(positions).length > 0) {
1960
+ wb.setPositions(positions, { commit });
1960
1961
  }
1961
1962
  }, [wb]);
1962
1963
  const onEdgesDelete = React.useCallback((edges) => edges.forEach((e, idx) => wb.disconnect(e.id, { commit: idx === edges.length - 1 })), [wb]);
@@ -1988,10 +1989,7 @@ function useWorkbenchBridge(wb) {
1988
1989
  }
1989
1990
  }
1990
1991
  if (selectionChanged) {
1991
- wb.setSelection({
1992
- nodes: current.nodes,
1993
- edges: Array.from(nextEdgeIds),
1994
- });
1992
+ wb.setSelection({ nodes: current.nodes, edges: Array.from(nextEdgeIds) }, { commit: true });
1995
1993
  }
1996
1994
  }, [wb]);
1997
1995
  const onNodesDelete = React.useCallback((nodes) => {
@@ -2439,6 +2437,7 @@ async function download(wb, runner) {
2439
2437
  try {
2440
2438
  const def = wb.export();
2441
2439
  const uiState = wb.getUIState();
2440
+ const runtimeState = wb.getRuntimeState();
2442
2441
  let snapshot;
2443
2442
  if (runner.isRunning()) {
2444
2443
  const fullSnapshot = await runner.snapshotFull();
@@ -2448,6 +2447,7 @@ async function download(wb, runner) {
2448
2447
  extData: {
2449
2448
  ...(fullSnapshot.extData || {}),
2450
2449
  ui: uiState,
2450
+ runtime: runtimeState || undefined,
2451
2451
  },
2452
2452
  };
2453
2453
  }
@@ -2458,7 +2458,7 @@ async function download(wb, runner) {
2458
2458
  inputs,
2459
2459
  outputs: {},
2460
2460
  environment: {},
2461
- extData: { ui: uiState },
2461
+ extData: { ui: uiState, runtime: runtimeState || undefined },
2462
2462
  };
2463
2463
  }
2464
2464
  downloadJSON(snapshot, `spark-snapshot-${generateTimestamp()}.json`);
@@ -2484,6 +2484,9 @@ async function upload(parsed, wb, runner) {
2484
2484
  if (extData.ui && typeof extData.ui === "object") {
2485
2485
  wb.setUIState(extData.ui);
2486
2486
  }
2487
+ if (extData.runtime && typeof extData.runtime === "object") {
2488
+ wb.setRuntimeState(extData.runtime);
2489
+ }
2487
2490
  if (runner.isRunning()) {
2488
2491
  await runner.applySnapshotFull({
2489
2492
  def,
@@ -2511,6 +2514,18 @@ function useWorkbenchContext() {
2511
2514
  return ctx;
2512
2515
  }
2513
2516
 
2517
+ // Helper to compute invalidated status from runtime metadata
2518
+ function computeInvalidatedFromMetadata(metadata) {
2519
+ if (!metadata)
2520
+ return true;
2521
+ const { lastSuccessAt, lastInputAt, lastRunAt } = metadata;
2522
+ if (!lastSuccessAt && !lastRunAt)
2523
+ return true;
2524
+ if (!lastInputAt || Object.keys(lastInputAt).length === 0)
2525
+ return false;
2526
+ const maxInputTime = Math.max(...Object.values(lastInputAt));
2527
+ return maxInputTime > (lastSuccessAt ?? lastRunAt ?? 0);
2528
+ }
2514
2529
  function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVersion, children, }) {
2515
2530
  const [nodeStatus, setNodeStatus] = React.useState({});
2516
2531
  const [edgeStatus, setEdgeStatus] = React.useState({});
@@ -2601,27 +2616,35 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2601
2616
  }
2602
2617
  return out;
2603
2618
  }, [def, outputsMap, registry]);
2604
- // Initialize nodes as invalidated by default until first successful run
2619
+ // Initialize nodes and derive invalidated status from persisted metadata
2605
2620
  React.useEffect(() => {
2621
+ const workbenchRuntimeState = wb.getRuntimeState() ?? { nodes: {} };
2606
2622
  setNodeStatus((prev) => {
2607
2623
  const next = { ...prev };
2624
+ const metadata = workbenchRuntimeState;
2608
2625
  for (const n of def.nodes) {
2609
2626
  const cur = next[n.nodeId] ?? (next[n.nodeId] = {});
2627
+ const nodeMeta = metadata.nodes[n.nodeId];
2610
2628
  const updates = {};
2611
2629
  if (cur.invalidated === undefined) {
2612
- updates.invalidated = true;
2630
+ updates.invalidated = computeInvalidatedFromMetadata(nodeMeta);
2613
2631
  }
2614
- // Ensure activeRunIds is always initialized as an array
2615
2632
  if (cur.activeRunIds === undefined) {
2616
2633
  updates.activeRunIds = [];
2617
2634
  }
2635
+ if (cur.activeRuns === undefined) {
2636
+ updates.activeRuns = 0;
2637
+ }
2638
+ if (nodeMeta?.lastErrorSummary && cur.lastError === undefined) {
2639
+ updates.lastError = nodeMeta.lastErrorSummary;
2640
+ }
2618
2641
  if (Object.keys(updates).length > 0) {
2619
2642
  next[n.nodeId] = { ...cur, ...updates };
2620
2643
  }
2621
2644
  }
2622
2645
  return next;
2623
2646
  });
2624
- }, [def]);
2647
+ }, [def, wb]);
2625
2648
  // Auto layout (simple layered layout)
2626
2649
  const runAutoLayout = React.useCallback(() => {
2627
2650
  const cur = wb.export();
@@ -2692,6 +2715,27 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2692
2715
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2693
2716
  const updateEdgeType = React.useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2694
2717
  const triggerExternal = React.useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
2718
+ // Helper to save runtime metadata to extData.runtime and workbench state
2719
+ const saveRuntimeMetadata = React.useCallback(async () => {
2720
+ try {
2721
+ const current = wb.getRuntimeState() ?? { nodes: {} };
2722
+ const metadata = { nodes: { ...current.nodes } };
2723
+ // Clean up metadata for nodes that no longer exist
2724
+ const nodeIds = new Set(def.nodes.map((n) => n.nodeId));
2725
+ for (const nodeId of Object.keys(metadata.nodes)) {
2726
+ if (!nodeIds.has(nodeId)) {
2727
+ delete metadata.nodes[nodeId];
2728
+ }
2729
+ }
2730
+ // Save cleaned metadata to workbench state
2731
+ wb.setRuntimeState(metadata);
2732
+ // Save to extData.runtime via runner (no snapshotFull)
2733
+ await runner.setExtData?.({ runtime: metadata });
2734
+ }
2735
+ catch (err) {
2736
+ console.warn("[WorkbenchContext] Failed to save runtime metadata:", err);
2737
+ }
2738
+ }, [wb, def, runner]);
2695
2739
  // Subscribe to runner/workbench events
2696
2740
  React.useEffect(() => {
2697
2741
  const add = (source, type) => (payload) => setEvents((prev) => {
@@ -2734,9 +2778,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2734
2778
  wb.refreshValidation();
2735
2779
  };
2736
2780
  const offRunnerValue = runner.on("value", (e) => {
2781
+ const now = Date.now();
2737
2782
  if (e?.io === "input") {
2738
- const nodeId = e?.nodeId;
2739
- const handle = e?.handle;
2783
+ const nodeId = e.nodeId;
2784
+ const handle = e.handle;
2785
+ // Track input timestamp in workbench runtime state
2786
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2787
+ ...nodeMeta,
2788
+ lastInputAt: {
2789
+ ...(nodeMeta.lastInputAt ?? {}),
2790
+ [handle]: now,
2791
+ },
2792
+ }));
2740
2793
  setNodeStatus((s) => ({
2741
2794
  ...s,
2742
2795
  [nodeId]: { ...s[nodeId], invalidated: true },
@@ -2744,6 +2797,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2744
2797
  // Clear validation errors for this input when a valid value is set
2745
2798
  setInputValidationErrors((prev) => prev.filter((err) => !(err.nodeId === nodeId && err.handle === handle)));
2746
2799
  }
2800
+ else if (e?.io === "output") {
2801
+ const nodeId = e.nodeId;
2802
+ const handle = e.handle;
2803
+ // Track output timestamp in workbench runtime state
2804
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2805
+ ...nodeMeta,
2806
+ lastOutputAt: {
2807
+ ...(nodeMeta.lastOutputAt ?? {}),
2808
+ [handle]: now,
2809
+ },
2810
+ }));
2811
+ }
2747
2812
  return add("runner", "value")(e);
2748
2813
  });
2749
2814
  const offRunnerError = runner.on("error", (e) => {
@@ -2762,6 +2827,24 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2762
2827
  else if (nodeError.kind === "node-run" && nodeError.nodeId) {
2763
2828
  const nodeId = nodeError.nodeId;
2764
2829
  const runId = nodeError.runId;
2830
+ const now = Date.now();
2831
+ // Track error timestamp and summary in workbench runtime state
2832
+ const err = nodeError.err;
2833
+ let errorSummary;
2834
+ if (err && typeof err === "object") {
2835
+ const message = err.message || String(err);
2836
+ const code = err.code || err.statusCode;
2837
+ errorSummary = {
2838
+ message: typeof message === "string" ? message : String(message),
2839
+ code: typeof code === "number" ? code : undefined,
2840
+ };
2841
+ }
2842
+ wb.updateNodeRuntimeMetadata(nodeId, (nodeMeta) => ({
2843
+ ...nodeMeta,
2844
+ lastErrorAt: now,
2845
+ lastRunAt: now,
2846
+ ...(errorSummary ? { lastErrorSummary: errorSummary } : {}),
2847
+ }));
2765
2848
  setNodeStatus((s) => ({
2766
2849
  ...s,
2767
2850
  [nodeId]: {
@@ -2816,6 +2899,30 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2816
2899
  // If resolvedHandles are included in the event, use them directly (more efficient)
2817
2900
  if (e?.resolvedHandles && Object.keys(e.resolvedHandles).length > 0) {
2818
2901
  applyResolvedHandles(e.resolvedHandles);
2902
+ // Mark nodes whose handles changed as invalid
2903
+ const affectedNodeIds = Object.keys(e.resolvedHandles);
2904
+ if (affectedNodeIds.length > 0) {
2905
+ setNodeStatus((prev) => {
2906
+ const next = { ...prev };
2907
+ for (const id of affectedNodeIds) {
2908
+ const cur = next[id] ?? (next[id] = { activeRuns: 0, activeRunIds: [] });
2909
+ next[id] = { ...cur, invalidated: true };
2910
+ }
2911
+ return next;
2912
+ });
2913
+ }
2914
+ }
2915
+ // For broader invalidations (e.g. registry-changed, graph-updated), mark all nodes invalid
2916
+ if (e?.reason === "registry-changed" || e?.reason === "graph-updated") {
2917
+ setNodeStatus((prev) => {
2918
+ const next = { ...prev };
2919
+ for (const n of def.nodes) {
2920
+ const cur = next[n.nodeId] ??
2921
+ (next[n.nodeId] = { activeRuns: 0, activeRunIds: [] });
2922
+ next[n.nodeId] = { ...cur, invalidated: true };
2923
+ }
2924
+ return next;
2925
+ });
2819
2926
  }
2820
2927
  return add("runner", "invalidate")(e);
2821
2928
  });
@@ -2825,6 +2932,12 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2825
2932
  if (s.kind === "node-start") {
2826
2933
  const id = s.nodeId;
2827
2934
  const runId = s.runId;
2935
+ const now = Date.now();
2936
+ // Track run timestamp in workbench runtime state
2937
+ wb.updateNodeRuntimeMetadata(id, (nodeMeta) => ({
2938
+ ...nodeMeta,
2939
+ lastRunAt: now,
2940
+ }));
2828
2941
  // Validate runId is a non-empty string
2829
2942
  const isValidRunId = runId && typeof runId === "string" && runId.length > 0;
2830
2943
  if (!isValidRunId) {
@@ -2847,7 +2960,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2847
2960
  };
2848
2961
  });
2849
2962
  // Start fallback animation window
2850
- setFallbackStarts((prev) => ({ ...prev, [id]: Date.now() }));
2963
+ setFallbackStarts((prev) => ({ ...prev, [id]: now }));
2851
2964
  }
2852
2965
  else if (s.kind === "node-progress") {
2853
2966
  const id = s.nodeId;
@@ -2862,8 +2975,21 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2862
2975
  else if (s.kind === "node-done") {
2863
2976
  const id = s.nodeId;
2864
2977
  const runId = s.runId;
2978
+ const now = Date.now();
2865
2979
  // Validate runId is a non-empty string
2866
2980
  const isValidRunId = runId && typeof runId === "string" && runId.length > 0;
2981
+ const hadError = !!(isValidRunId && errorRunsRef.current[id]?.[runId]);
2982
+ // Track success timestamp if no error in workbench runtime state
2983
+ if (!hadError) {
2984
+ wb.updateNodeRuntimeMetadata(id, (nodeMeta) => {
2985
+ const updated = { ...nodeMeta, lastSuccessAt: now };
2986
+ // Clear error summary on success
2987
+ if (updated.lastErrorSummary) {
2988
+ delete updated.lastErrorSummary;
2989
+ }
2990
+ return updated;
2991
+ });
2992
+ }
2867
2993
  setNodeStatus((prev) => {
2868
2994
  const current = prev[id]?.activeRuns ?? 0;
2869
2995
  const currentRunIds = prev[id]?.activeRunIds ?? [];
@@ -2875,7 +3001,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2875
3001
  const nextRunIds = isValidRunId
2876
3002
  ? currentRunIds.filter((rid) => rid !== runId)
2877
3003
  : currentRunIds;
2878
- const hadError = !!(isValidRunId && errorRunsRef.current[id]?.[runId]);
2879
3004
  const keepProgress = hadError || nextActive > 0;
2880
3005
  // Clear error flag for this runId
2881
3006
  if (isValidRunId && errorRunsRef.current[id]?.[runId]) {
@@ -2969,10 +3094,14 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2969
3094
  }
2970
3095
  if (!runner.isRunning()) {
2971
3096
  if (event.commit) {
2972
- // If runner not running, commit immediately (no update needed)
2973
- await runner.commit(reason).catch((err) => {
3097
+ await saveRuntimeMetadata();
3098
+ const history = await runner.commit(reason).catch((err) => {
2974
3099
  console.error("[WorkbenchContext] Error committing:", err);
3100
+ return undefined;
2975
3101
  });
3102
+ if (history) {
3103
+ wb.setHistory(history);
3104
+ }
2976
3105
  }
2977
3106
  return;
2978
3107
  }
@@ -2999,10 +3128,16 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2999
3128
  await runner.update(event.def, { dry: event.dry });
3000
3129
  }
3001
3130
  if (event.commit) {
3002
- // Wait for update to complete, then commit
3003
- await runner.commit(event.reason ?? reason).catch((err) => {
3131
+ await saveRuntimeMetadata();
3132
+ const history = await runner
3133
+ .commit(event.reason ?? reason)
3134
+ .catch((err) => {
3004
3135
  console.error("[WorkbenchContext] Error committing after update:", err);
3136
+ return undefined;
3005
3137
  });
3138
+ if (history) {
3139
+ wb.setHistory(history);
3140
+ }
3006
3141
  }
3007
3142
  }
3008
3143
  catch (err) {
@@ -3014,23 +3149,48 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3014
3149
  setSelectedNodeId(sel.nodes?.[0]);
3015
3150
  setSelectedEdgeId(sel.edges?.[0]);
3016
3151
  if (sel.commit) {
3017
- // Commit on selection change
3018
- await runner.commit(sel.reason ?? "selection").catch((err) => {
3152
+ await saveRuntimeMetadata();
3153
+ const history = await runner
3154
+ .commit(sel.reason ?? "selection")
3155
+ .catch((err) => {
3019
3156
  console.error("[WorkbenchContext] Error committing selection change:", err);
3157
+ return undefined;
3020
3158
  });
3159
+ if (history) {
3160
+ wb.setHistory(history);
3161
+ }
3021
3162
  }
3022
3163
  });
3023
3164
  const offWbGraphUiChanged = wb.on("graphUiChanged", async (event) => {
3024
3165
  // Only commit if commit flag is true (e.g., drag end, not during dragging)
3025
3166
  if (event.commit) {
3167
+ // Build detailed reason from change type
3168
+ let reason = "ui-changed";
3026
3169
  if (event.change) {
3027
- event.change.type;
3170
+ const changeType = event.change.type;
3171
+ if (changeType === "moveNode") {
3172
+ reason = "move-node";
3173
+ }
3174
+ else if (changeType === "moveNodes") {
3175
+ reason = "move-nodes";
3176
+ }
3177
+ else if (changeType === "selection") {
3178
+ reason = "selection";
3179
+ }
3180
+ else if (changeType === "viewport") {
3181
+ reason = "viewport";
3182
+ }
3028
3183
  }
3029
- await runner
3030
- .commit(event.reason ?? "ui-changed")
3184
+ await saveRuntimeMetadata();
3185
+ const history = await runner
3186
+ .commit(event.reason ?? reason)
3031
3187
  .catch((err) => {
3032
3188
  console.error("[WorkbenchContext] Error committing UI changes:", err);
3189
+ return undefined;
3033
3190
  });
3191
+ if (history) {
3192
+ wb.setHistory(history);
3193
+ }
3034
3194
  }
3035
3195
  });
3036
3196
  const offWbError = wb.on("error", add("workbench", "error"));
@@ -3051,11 +3211,24 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3051
3211
  console.error("Failed to handle registry changed event");
3052
3212
  }
3053
3213
  });
3054
- // Handle transport disconnect: reset runtime status when connection is lost
3214
+ // Handle transport changes: reset runtime status when connection is lost
3055
3215
  const offRunnerTransport = runner.on("transport", (t) => {
3056
3216
  if (t.state === "disconnected") {
3057
3217
  console.info("[WorkbenchContext] Transport disconnected, resetting node status");
3058
- setNodeStatus({});
3218
+ // Reinitialize node status with invalidated=true for all nodes
3219
+ setNodeStatus(() => {
3220
+ const next = {};
3221
+ const metadata = wb.getRuntimeState() ?? { nodes: {} };
3222
+ for (const n of def.nodes) {
3223
+ const nodeMeta = metadata.nodes[n.nodeId];
3224
+ next[n.nodeId] = {
3225
+ activeRuns: 0,
3226
+ activeRunIds: [],
3227
+ invalidated: computeInvalidatedFromMetadata(nodeMeta),
3228
+ };
3229
+ }
3230
+ return next;
3231
+ });
3059
3232
  setEdgeStatus({});
3060
3233
  setFallbackStarts({});
3061
3234
  errorRunsRef.current = {};
@@ -3453,7 +3626,7 @@ function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onC
3453
3626
  /**
3454
3627
  * Creates base default context menu handlers.
3455
3628
  */
3456
- function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData) {
3629
+ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, getCopiedData, clearCopiedData, history) {
3457
3630
  // Wrap paste handler to clear storage after paste
3458
3631
  const wrappedOnPaste = onPaste && getCopiedData && clearCopiedData
3459
3632
  ? (position) => {
@@ -3461,16 +3634,17 @@ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, g
3461
3634
  clearCopiedData();
3462
3635
  }
3463
3636
  : onPaste;
3464
- // Function to check if paste data exists (called dynamically when menu opens)
3465
3637
  const hasPasteData = getCopiedData ? () => !!getCopiedData() : undefined;
3638
+ const canUndo = history ? history.undoCount > 0 : undefined;
3639
+ const canRedo = history ? history.redoCount > 0 : undefined;
3466
3640
  return {
3467
3641
  onAddNode,
3468
3642
  onPaste: wrappedOnPaste,
3469
3643
  hasPasteData,
3470
3644
  onUndo: runner ? () => runner.undo().then(() => onClose()) : undefined,
3471
3645
  onRedo: runner ? () => runner.redo().then(() => onClose()) : undefined,
3472
- canUndo: runner ? () => runner.canUndo().catch(() => false) : undefined,
3473
- canRedo: runner ? () => runner.canRedo().catch(() => false) : undefined,
3646
+ canUndo,
3647
+ canRedo,
3474
3648
  onClose,
3475
3649
  };
3476
3650
  }
@@ -3994,6 +4168,16 @@ function DefaultNodeContent({ data, isConnectable, }) {
3994
4168
  } })] }));
3995
4169
  }
3996
4170
 
4171
+ // Helper to format shortcut for current platform
4172
+ function formatShortcut(shortcut) {
4173
+ const isMac = typeof navigator !== "undefined" &&
4174
+ navigator.userAgent.toLowerCase().includes("mac");
4175
+ return shortcut.replace(/⌘\/Ctrl/g, isMac ? "⌘" : "Ctrl");
4176
+ }
4177
+ function ContextMenuButton({ label, onClick, disabled = false, shortcut, enableKeyboardShortcuts = true, }) {
4178
+ 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) }))] }));
4179
+ }
4180
+
3997
4181
  function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enableKeyboardShortcuts = true, keyboardShortcuts = {
3998
4182
  undo: "⌘/Ctrl + Z",
3999
4183
  redo: "⌘/Ctrl + Shift + Z",
@@ -4001,41 +4185,24 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4001
4185
  }, }) {
4002
4186
  const rf = react.useReactFlow();
4003
4187
  const [query, setQuery] = React.useState("");
4004
- const [canUndo, setCanUndo] = React.useState(false);
4005
- const [canRedo, setCanRedo] = React.useState(false);
4006
4188
  const [hasPasteData, setHasPasteData] = React.useState(false);
4007
4189
  const q = query.trim().toLowerCase();
4008
4190
  const filteredIds = q
4009
4191
  ? nodeIds.filter((id) => id.toLowerCase().includes(q))
4010
4192
  : nodeIds;
4011
- // Check undo/redo availability and paste data when menu opens
4193
+ const canUndo = handlers.canUndo ?? false;
4194
+ const canRedo = handlers.canRedo ?? false;
4012
4195
  React.useEffect(() => {
4013
4196
  if (!open)
4014
4197
  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]);
4198
+ if (handlers.hasPasteData) {
4199
+ const result = handlers.hasPasteData();
4200
+ setHasPasteData(result);
4201
+ }
4202
+ else {
4203
+ setHasPasteData(false);
4204
+ }
4205
+ }, [open, handlers.hasPasteData]);
4039
4206
  const root = { __children: {} };
4040
4207
  for (const id of filteredIds) {
4041
4208
  const parts = id.split(".");
@@ -4100,12 +4267,6 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4100
4267
  handlers.onPaste(p);
4101
4268
  handlers.onClose();
4102
4269
  };
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
4270
  const renderTree = (tree, path = []) => {
4110
4271
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
4111
4272
  return (jsxRuntime.jsx("div", { children: entries.map(([key, child]) => {
@@ -4123,7 +4284,7 @@ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, enab
4123
4284
  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
4285
  e.preventDefault();
4125
4286
  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 &&
4287
+ }, 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
4288
  handlers.onPaste &&
4128
4289
  !handlers.onUndo &&
4129
4290
  !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 +4321,6 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4160
4321
  if (open)
4161
4322
  ref.current?.focus();
4162
4323
  }, [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
4324
  if (!open || !clientPos || !nodeId)
4170
4325
  return null;
4171
4326
  // clamp
@@ -4177,7 +4332,7 @@ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeab
4177
4332
  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
4333
  e.preventDefault();
4179
4334
  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)))] }))] }));
4335
+ }, 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
4336
  }
4182
4337
 
4183
4338
  function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcuts = true, keyboardShortcuts = {
@@ -4210,12 +4365,6 @@ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcu
4210
4365
  if (open)
4211
4366
  ref.current?.focus();
4212
4367
  }, [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
4368
  if (!open || !clientPos)
4220
4369
  return null;
4221
4370
  // Clamp menu position to viewport
@@ -4227,7 +4376,7 @@ function SelectionContextMenu({ open, clientPos, handlers, enableKeyboardShortcu
4227
4376
  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
4377
  e.preventDefault();
4229
4378
  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) }))] })] }));
4379
+ }, 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
4380
  }
4232
4381
 
4233
4382
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
@@ -4235,7 +4384,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4235
4384
  const nodeValidation = validationByNode;
4236
4385
  const edgeValidation = validationByEdge.errors;
4237
4386
  const [registryVersion, setRegistryVersion] = React.useState(0);
4238
- // Keep stable references for nodes/edges to avoid unnecessary updates
4387
+ const [historyState, setHistoryState] = React.useState(wb.getHistory());
4239
4388
  const prevNodesRef = React.useRef([]);
4240
4389
  const prevEdgesRef = React.useRef([]);
4241
4390
  function retainStabilityById(prev, next, isSame) {
@@ -4581,6 +4730,12 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4581
4730
  });
4582
4731
  return () => off();
4583
4732
  }, [runner]);
4733
+ React.useEffect(() => {
4734
+ const off = wb.on("historyChanged", (event) => {
4735
+ setHistoryState(event.history);
4736
+ });
4737
+ return () => off();
4738
+ }, [wb]);
4584
4739
  const nodeIds = React.useMemo(() => Array.from(registry.nodes.keys()), [registry, registryVersion]);
4585
4740
  const defaultContextMenuHandlers = React.useMemo(() => {
4586
4741
  // Get storage from override or use workbench's internal storage
@@ -4590,21 +4745,18 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4590
4745
  get: () => wb.getCopiedData(),
4591
4746
  set: (data) => wb.setCopiedData(data),
4592
4747
  };
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) => {
4748
+ const baseHandlers = createDefaultContextMenuHandlers(addNodeAt, onCloseMenu, (position) => {
4597
4749
  const data = storage.get();
4598
4750
  if (!data)
4599
4751
  return;
4600
4752
  wb.pasteCopiedData(data, position, { commit: true, reason: "paste" });
4601
4753
  onCloseMenu();
4602
- }, runner, () => storage.get(), () => storage.set(null));
4754
+ }, runner, () => storage.get(), () => storage.set(null), historyState);
4603
4755
  if (overrides?.getDefaultContextMenuHandlers) {
4604
4756
  return overrides.getDefaultContextMenuHandlers(wb, baseHandlers);
4605
4757
  }
4606
4758
  return baseHandlers;
4607
- }, [addNodeAt, onCloseMenu, overrides, wb, runner]);
4759
+ }, [addNodeAt, onCloseMenu, overrides, wb, runner, historyState]);
4608
4760
  const selectionContextMenuHandlers = React.useMemo(() => {
4609
4761
  // Get storage from override or use workbench's internal storage
4610
4762
  const storage = overrides?.getCopiedDataStorage
@@ -4689,9 +4841,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4689
4841
  e.preventDefault();
4690
4842
  if (runner &&
4691
4843
  "onUndo" in defaultContextMenuHandlers &&
4692
- defaultContextMenuHandlers.onUndo) {
4693
- const canUndo = await runner.canUndo().catch(() => false);
4694
- if (canUndo) {
4844
+ defaultContextMenuHandlers.onUndo &&
4845
+ defaultContextMenuHandlers.canUndo) {
4846
+ if (defaultContextMenuHandlers.canUndo) {
4695
4847
  defaultContextMenuHandlers.onUndo();
4696
4848
  }
4697
4849
  }
@@ -4702,9 +4854,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4702
4854
  e.preventDefault();
4703
4855
  if (runner &&
4704
4856
  "onRedo" in defaultContextMenuHandlers &&
4705
- defaultContextMenuHandlers.onRedo) {
4706
- const canRedo = await runner.canRedo().catch(() => false);
4707
- if (canRedo) {
4857
+ defaultContextMenuHandlers.onRedo &&
4858
+ defaultContextMenuHandlers.canRedo) {
4859
+ if (defaultContextMenuHandlers.canRedo) {
4708
4860
  defaultContextMenuHandlers.onRedo();
4709
4861
  }
4710
4862
  }