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