@dfosco/storyboard 0.6.12 → 0.6.13

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.
@@ -28,7 +28,10 @@ import {
28
28
  duplicateImage,
29
29
  fetchGitHubEmbed,
30
30
  getCanvas as getCanvasApi,
31
+ patchWidget as patchWidgetApi,
31
32
  removeWidget as removeWidgetApi,
33
+ redoEvent as redoEventApi,
34
+ undoEvent as undoEventApi,
32
35
  updateCanvas,
33
36
  updateFolderMeta,
34
37
  uploadImage,
@@ -950,27 +953,66 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
950
953
  }
951
954
  }
952
955
 
953
- // Debounced save to server routed through queueWrite to serialize
954
- // with deletes and other writes, preventing stale data from overwriting.
955
- const debouncedSave = useRef(
956
- debounce((canvasId, widgets) => {
957
- // De-duplicate widgets by ID — race conditions between HMR pushes
958
- // and optimistic adds can introduce duplicates in localWidgets.
959
- const seen = new Set()
960
- const deduped = widgets.filter((w) => {
961
- if (seen.has(w.id)) return false
962
- seen.add(w.id)
963
- return true
964
- })
965
- queueWrite(() =>
966
- updateCanvas(canvasId, { widgets: deduped })
967
- .catch((err) => console.error('[canvas] Failed to save:', err))
968
- )
969
- }, 2000)
970
- ).current
956
+ // Per-widget debounced PATCH. Each widget gets its own pending props
957
+ // accumulator + timer so concurrent edits to different widgets don't stomp
958
+ // on one another. Successful patches return event ids — we push them onto
959
+ // the undo stack via undoRedo.trackMany so the toolbar Undo button targets
960
+ // exactly the right granular events.
961
+ const widgetPatchDebouncersRef = useRef(new Map())
962
+ const flushAllPendingWidgetPatches = useCallback(() => {
963
+ for (const entry of widgetPatchDebouncersRef.current.values()) {
964
+ if (entry.timer) {
965
+ clearTimeout(entry.timer)
966
+ entry.timer = null
967
+ entry.flushNow()
968
+ }
969
+ }
970
+ }, [])
971
+ const cancelAllPendingWidgetPatches = useCallback(() => {
972
+ for (const entry of widgetPatchDebouncersRef.current.values()) {
973
+ if (entry.timer) {
974
+ clearTimeout(entry.timer)
975
+ entry.timer = null
976
+ entry.pendingProps = {}
977
+ }
978
+ }
979
+ }, [])
980
+ // patchWidgetDebounced(canvasId, widgetId, propsDelta) — coalesces multiple
981
+ // PATCHes per widget within DEBOUNCE_MS into a single request.
982
+ const patchWidgetDebounced = useCallback((targetCanvasId, widgetId, propsDelta) => {
983
+ if (!widgetId || !propsDelta) return
984
+ const map = widgetPatchDebouncersRef.current
985
+ let entry = map.get(widgetId)
986
+ if (!entry) {
987
+ entry = {
988
+ pendingProps: {},
989
+ timer: null,
990
+ flushNow: () => {
991
+ const props = entry.pendingProps
992
+ entry.pendingProps = {}
993
+ if (Object.keys(props).length === 0) return
994
+ dirtyRef.current = true
995
+ queueWrite(async () => {
996
+ try {
997
+ const res = await patchWidgetApi(targetCanvasId, widgetId, { props })
998
+ if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
999
+ } catch (err) {
1000
+ console.error('[canvas] Failed to patch widget:', err)
1001
+ }
1002
+ })
1003
+ },
1004
+ }
1005
+ map.set(widgetId, entry)
1006
+ }
1007
+ Object.assign(entry.pendingProps, propsDelta)
1008
+ if (entry.timer) clearTimeout(entry.timer)
1009
+ entry.timer = setTimeout(() => {
1010
+ entry.timer = null
1011
+ entry.flushNow()
1012
+ }, 500)
1013
+ }, [undoRedo])
971
1014
 
972
1015
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
973
- undoRedo.snapshot(stateRef.current, 'edit', widgetId)
974
1016
  // Snap width/height to grid when snap is enabled
975
1017
  const snapped = { ...updates }
976
1018
  if (snapEnabled && snapGridSize) {
@@ -985,11 +1027,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
985
1027
  const next = prev.map((w) =>
986
1028
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
987
1029
  )
988
- dirtyRef.current = true
989
- debouncedSave(canvasId, next)
990
1030
  return next
991
1031
  })
992
- }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
1032
+ dirtyRef.current = true
1033
+ patchWidgetDebounced(canvasId, widgetId, snapped)
1034
+ }, [canvasId, patchWidgetDebounced, snapEnabled, snapGridSize])
993
1035
 
994
1036
  // Stable ref so handleWidgetSelect can flip a "done" agent back to
995
1037
  // "running" without re-creating itself on every widget update.
@@ -1002,35 +1044,60 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1002
1044
  const defaultMeta = hubRoleOptions.find((r) => r.id === defaultHubRole) || null
1003
1045
  const fallbackRoleId = defaultMeta?.id || 'member'
1004
1046
 
1005
- undoRedo.snapshot(stateRef.current, 'edit-role', widgetId)
1006
1047
  setLocalWidgets((prev) => {
1007
1048
  if (!prev) return prev
1008
1049
 
1009
1050
  const componentIds = getConnectedComponent(widgetId, localConnectors)
1010
1051
  const scopeHas = (id) => componentIds.has(id)
1052
+ const roleChanges = [] // { widgetId, role }
1011
1053
  const next = prev.map((widget) => {
1012
1054
  if (widget.id === widgetId) {
1055
+ roleChanges.push({ widgetId: widget.id, role: targetRole })
1013
1056
  return { ...widget, props: { ...widget.props, role: targetRole } }
1014
1057
  }
1015
1058
  if (!roleMeta || roleMeta.type !== 'unique') return widget
1016
1059
  if (widget.type !== 'agent' && widget.type !== 'prompt') return widget
1017
1060
  if (!scopeHas(widget.id)) return widget
1018
1061
  if ((widget.props?.role || fallbackRoleId) !== targetRole) return widget
1062
+ roleChanges.push({ widgetId: widget.id, role: fallbackRoleId })
1019
1063
  return { ...widget, props: { ...widget.props, role: fallbackRoleId } }
1020
1064
  })
1021
1065
 
1022
1066
  dirtyRef.current = true
1023
- debouncedSave(canvasId, next)
1067
+ // Persist as a single batch so undo restores all role flips atomically.
1068
+ if (roleChanges.length > 0) {
1069
+ const operations = roleChanges.map((c) => ({
1070
+ op: 'update-widget',
1071
+ widgetId: c.widgetId,
1072
+ props: { role: c.role },
1073
+ }))
1074
+ queueWrite(async () => {
1075
+ try {
1076
+ const res = await batchOperations(canvasId, operations)
1077
+ const ids = (res?.results || [])
1078
+ .map((r) => r?.eventId)
1079
+ .filter(Boolean)
1080
+ if (ids.length > 0) undoRedo.trackMany(ids)
1081
+ } catch (err) {
1082
+ console.error('[canvas] Failed to persist role change:', err)
1083
+ }
1084
+ })
1085
+ }
1024
1086
  return next
1025
1087
  })
1026
- }, [canvasId, debouncedSave, undoRedo, defaultHubRole, hubRoleOptions, localConnectors])
1088
+ }, [canvasId, undoRedo, defaultHubRole, hubRoleOptions, localConnectors])
1027
1089
 
1028
1090
  const handleWidgetRemove = useCallback((widgetId) => {
1029
- // Cancel any pending debounced save it may contain stale data
1030
- // that includes the widget we're about to delete
1031
- debouncedSave.cancel()
1091
+ // Cancel any pending widget patches that target this widget they
1092
+ // would race against the delete and leave a stale widget_updated event
1093
+ // in the JSONL.
1094
+ const debouncer = widgetPatchDebouncersRef.current.get(widgetId)
1095
+ if (debouncer?.timer) {
1096
+ clearTimeout(debouncer.timer)
1097
+ debouncer.timer = null
1098
+ debouncer.pendingProps = {}
1099
+ }
1032
1100
 
1033
- undoRedo.snapshot(stateRef.current, 'remove', widgetId)
1034
1101
  markWidgetDeleted(widgetId)
1035
1102
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
1036
1103
  // Cascade: remove connectors referencing this widget
@@ -1039,27 +1106,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1039
1106
  if (orphaned.length === 0) return prev
1040
1107
  for (const c of orphaned) {
1041
1108
  markConnectorDeleted(c.id)
1042
- queueWrite(() =>
1043
- removeConnectorApi(canvasId, c.id).catch((err) =>
1109
+ queueWrite(async () => {
1110
+ try {
1111
+ const res = await removeConnectorApi(canvasId, c.id)
1112
+ if (res?.eventId) undoRedo.track(res.eventId)
1113
+ } catch (err) {
1044
1114
  console.error('[canvas] Failed to remove orphaned connector:', err)
1045
- )
1046
- )
1115
+ }
1116
+ })
1047
1117
  }
1048
1118
  return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
1049
1119
  })
1050
1120
  dirtyRef.current = true
1051
- queueWrite(() =>
1052
- removeWidgetApi(canvasId, widgetId)
1053
- .catch((err) => console.error('[canvas] Failed to remove widget:', err))
1054
- )
1055
- }, [canvasId, undoRedo, debouncedSave, markWidgetDeleted, markConnectorDeleted])
1121
+ queueWrite(async () => {
1122
+ try {
1123
+ const res = await removeWidgetApi(canvasId, widgetId)
1124
+ if (res?.eventId) undoRedo.track(res.eventId)
1125
+ } catch (err) {
1126
+ console.error('[canvas] Failed to remove widget:', err)
1127
+ }
1128
+ })
1129
+ }, [canvasId, undoRedo, markWidgetDeleted, markConnectorDeleted])
1056
1130
 
1057
1131
  const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
1058
1132
  try {
1059
- undoRedo.snapshot(stateRef.current, 'connector-add')
1060
1133
  const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
1061
1134
  if (result.success && result.connector) {
1062
1135
  setLocalConnectors((prev) => [...prev, result.connector])
1136
+ if (result.eventId) undoRedo.track(result.eventId)
1063
1137
  }
1064
1138
  } catch (err) {
1065
1139
  console.error('[canvas] Failed to add connector:', err)
@@ -1099,15 +1173,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1099
1173
  }, [handleConnectorAdd])
1100
1174
 
1101
1175
  const handleConnectorRemove = useCallback((connectorId) => {
1102
- undoRedo.snapshot(stateRef.current, 'connector-remove')
1103
1176
  markConnectorDeleted(connectorId)
1104
1177
  setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
1105
1178
  dirtyRef.current = true
1106
- queueWrite(() =>
1107
- removeConnectorApi(canvasId, connectorId).catch((err) =>
1179
+ queueWrite(async () => {
1180
+ try {
1181
+ const res = await removeConnectorApi(canvasId, connectorId)
1182
+ if (res?.eventId) undoRedo.track(res.eventId)
1183
+ } catch (err) {
1108
1184
  console.error('[canvas] Failed to remove connector:', err)
1109
- )
1110
- )
1185
+ }
1186
+ })
1111
1187
  }, [canvasId, undoRedo, markConnectorDeleted])
1112
1188
 
1113
1189
  // Connector drag state
@@ -1293,7 +1369,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1293
1369
  if (dupResult.success) copyProps.src = dupResult.filename
1294
1370
  }
1295
1371
 
1296
- undoRedo.snapshot(stateRef.current, 'add')
1297
1372
  const result = await addWidgetApi(canvasId, {
1298
1373
  type: widget.type,
1299
1374
  props: copyProps,
@@ -1305,6 +1380,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1305
1380
  }
1306
1381
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1307
1382
  setSelectedWidgetIds(new Set([result.widget.id]))
1383
+ if (result.eventId) undoRedo.track(result.eventId)
1308
1384
  }
1309
1385
  } catch (err) {
1310
1386
  console.error('[canvas] Failed to copy widget:', err)
@@ -1316,8 +1392,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1316
1392
  if (!widget) return
1317
1393
  const widgets = [widget]
1318
1394
 
1319
- undoRedo.snapshot(stateRef.current, 'add')
1320
-
1321
1395
  const occupied = new Set(
1322
1396
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1323
1397
  )
@@ -1378,8 +1452,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1378
1452
  const newWidgets = []
1379
1453
  const newConnectors = []
1380
1454
  const refMap = response.refs || {}
1455
+ const eventIds = []
1381
1456
 
1382
1457
  for (const result of response.results) {
1458
+ if (result.eventId) eventIds.push(result.eventId)
1383
1459
  if (result.op === 'create-widget' && result.widget) {
1384
1460
  newWidgets.push(result.widget)
1385
1461
  }
@@ -1409,6 +1485,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1409
1485
  if (newConnectors.length > 0) {
1410
1486
  setLocalConnectors((prev) => [...prev, ...newConnectors])
1411
1487
  }
1488
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
1412
1489
  } catch (err) {
1413
1490
  console.error('[canvas] Failed to duplicate with connectors:', err)
1414
1491
  }
@@ -1419,9 +1496,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1419
1496
  const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1420
1497
  if (widgets.length === 0) return
1421
1498
 
1422
- // Single undo snapshot for the entire batch
1423
- undoRedo.snapshot(stateRef.current, 'add')
1424
-
1425
1499
  // Compute occupied positions to find free offset
1426
1500
  const occupied = new Set(
1427
1501
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
@@ -1435,6 +1509,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1435
1509
  while (anyOccupied()) offset++
1436
1510
 
1437
1511
  const newWidgets = []
1512
+ const eventIds = []
1438
1513
  for (const widget of widgets) {
1439
1514
  const position = {
1440
1515
  x: (widget.position?.x ?? 0) + offset * 40,
@@ -1460,6 +1535,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1460
1535
  result.widget.props = { ...result.widget.props, webglReady: true }
1461
1536
  }
1462
1537
  newWidgets.push(result.widget)
1538
+ if (result.eventId) eventIds.push(result.eventId)
1463
1539
  }
1464
1540
  } catch (err) {
1465
1541
  console.error('[canvas] Failed to duplicate widget:', err)
@@ -1470,6 +1546,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1470
1546
  setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1471
1547
  setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1472
1548
  }
1549
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
1473
1550
  }, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
1474
1551
 
1475
1552
  // Duplicate selected widgets WITH connectors (Cmd+Shift+D)
@@ -1479,8 +1556,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1479
1556
  const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1480
1557
  if (widgets.length === 0) return
1481
1558
 
1482
- undoRedo.snapshot(stateRef.current, 'add')
1483
-
1484
1559
  // Compute offset — same logic as handleDuplicateSelected
1485
1560
  const occupied = new Set(
1486
1561
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
@@ -1558,8 +1633,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1558
1633
  const newWidgets = []
1559
1634
  const newConnectors = []
1560
1635
  const refMap = response.refs || {}
1636
+ const eventIds = []
1561
1637
 
1562
1638
  for (const result of response.results) {
1639
+ if (result.eventId) eventIds.push(result.eventId)
1563
1640
  if (result.op === 'create-widget' && result.widget) {
1564
1641
  newWidgets.push(result.widget)
1565
1642
  }
@@ -1590,6 +1667,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1590
1667
  if (newConnectors.length > 0) {
1591
1668
  setLocalConnectors((prev) => [...prev, ...newConnectors])
1592
1669
  }
1670
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
1593
1671
  } catch (err) {
1594
1672
  console.error('[canvas] Failed to duplicate with connectors:', err)
1595
1673
  }
@@ -1663,17 +1741,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1663
1741
  }, [buildGitHubPreviewUpdates, handleWidgetUpdate])
1664
1742
 
1665
1743
  const debouncedSourceSave = useRef(
1666
- debounce((canvasId, sources) => {
1667
- queueWrite(() =>
1668
- updateCanvas(canvasId, { sources }).catch((err) =>
1744
+ debounce((canvasId, sources, onResult) => {
1745
+ queueWrite(async () => {
1746
+ try {
1747
+ const res = await updateCanvas(canvasId, { sources })
1748
+ if (typeof onResult === 'function') onResult(res)
1749
+ } catch (err) {
1669
1750
  console.error('[canvas] Failed to save sources:', err)
1670
- )
1671
- )
1751
+ }
1752
+ })
1672
1753
  }, 2000)
1673
1754
  ).current
1674
1755
 
1675
1756
  const handleSourceUpdate = useCallback((exportName, updates) => {
1676
- undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
1677
1757
  const snapped = { ...updates }
1678
1758
  if (snapEnabled && snapGridSize) {
1679
1759
  if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
@@ -1688,7 +1768,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1688
1768
  debouncedSourceSave(canvasId, next)
1689
1769
  return next
1690
1770
  })
1691
- }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
1771
+ }, [canvasId, debouncedSourceSave, snapEnabled, snapGridSize])
1692
1772
 
1693
1773
  const handleItemDragEnd = useCallback((dragId, position) => {
1694
1774
  setWidgetDragging(false)
@@ -1706,7 +1786,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1706
1786
  // Suppress the click-based selection reset that fires after pointerup
1707
1787
  justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
1708
1788
  requestAnimationFrame(() => { justDraggedRef.current = false })
1709
- undoRedo.snapshot(stateRef.current, 'multi-move')
1710
1789
 
1711
1790
  // Compute delta from the dragged widget's old position
1712
1791
  const isJsx = dragId.startsWith('jsx-')
@@ -1722,33 +1801,46 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1722
1801
  const dx = rounded.x - oldPos.x
1723
1802
  const dy = rounded.y - oldPos.y
1724
1803
 
1725
- debouncedSave.cancel()
1804
+ cancelAllPendingWidgetPatches()
1726
1805
 
1727
- // Update JSON widget positions
1806
+ // Update JSON widget positions. We collect move ops inside the setState
1807
+ // updater (which runs during commit) and fire the batch from there to
1808
+ // avoid the "set before commit" timing trap.
1728
1809
  setLocalWidgets((prev) => {
1729
1810
  if (!prev) return prev
1811
+ const widgetMoveOps = []
1730
1812
  const next = prev.map((w) => {
1731
- if (w.id === dragId) return { ...w, position: rounded }
1813
+ if (w.id === dragId) {
1814
+ widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: rounded })
1815
+ return { ...w, position: rounded }
1816
+ }
1732
1817
  if (ids.has(w.id)) {
1733
- return {
1734
- ...w,
1735
- position: {
1736
- x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
1737
- y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
1738
- },
1818
+ const newPos = {
1819
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
1820
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
1739
1821
  }
1822
+ widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: newPos })
1823
+ return { ...w, position: newPos }
1740
1824
  }
1741
1825
  return w
1742
1826
  })
1743
1827
  dirtyRef.current = true
1744
- queueWrite(() =>
1745
- updateCanvas(canvasId, { widgets: next })
1746
- .catch((err) => console.error('[canvas] Failed to save multi-move:', err))
1747
- )
1828
+ if (widgetMoveOps.length > 0) {
1829
+ queueWrite(async () => {
1830
+ try {
1831
+ const res = await batchOperations(canvasId, widgetMoveOps)
1832
+ const evIds = (res?.results || []).map((r) => r?.eventId).filter(Boolean)
1833
+ if (evIds.length > 0) undoRedo.trackMany(evIds)
1834
+ } catch (err) {
1835
+ console.error('[canvas] Failed to save multi-move:', err)
1836
+ }
1837
+ })
1838
+ }
1748
1839
  return next
1749
1840
  })
1750
1841
 
1751
- // Update JSX source positions
1842
+ // Update JSX source positions — still a single source_updated event;
1843
+ // not yet wired to per-source undo tracking.
1752
1844
  setLocalSources((prev) => {
1753
1845
  const current = Array.isArray(prev) ? prev : []
1754
1846
  let changed = false
@@ -1784,7 +1876,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1784
1876
  }
1785
1877
 
1786
1878
  if (dragId.startsWith('jsx-')) {
1787
- undoRedo.snapshot(stateRef.current, 'move', dragId)
1788
1879
  const sourceExport = dragId.replace(/^jsx-/, '')
1789
1880
  setLocalSources((prev) => {
1790
1881
  const current = Array.isArray(prev) ? prev : []
@@ -1801,21 +1892,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1801
1892
  return
1802
1893
  }
1803
1894
 
1804
- undoRedo.snapshot(stateRef.current, 'move', dragId)
1805
- debouncedSave.cancel()
1895
+ // Single-widget move — granular PATCH /widget so the move is its own
1896
+ // undoable event.
1897
+ cancelAllPendingWidgetPatches()
1806
1898
  setLocalWidgets((prev) => {
1807
1899
  if (!prev) return prev
1808
1900
  const next = prev.map((w) =>
1809
1901
  w.id === dragId ? { ...w, position: rounded } : w
1810
1902
  )
1811
1903
  dirtyRef.current = true
1812
- queueWrite(() =>
1813
- updateCanvas(canvasId, { widgets: next })
1814
- .catch((err) => console.error('[canvas] Failed to save widget position:', err))
1815
- )
1816
1904
  return next
1817
1905
  })
1818
- }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
1906
+ queueWrite(async () => {
1907
+ try {
1908
+ const res = await patchWidgetApi(canvasId, dragId, { position: rounded })
1909
+ if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
1910
+ } catch (err) {
1911
+ console.error('[canvas] Failed to save widget position:', err)
1912
+ }
1913
+ })
1914
+ }, [canvasId, undoRedo, cancelAllPendingWidgetPatches, transitionPeers, clearDragPreview])
1819
1915
 
1820
1916
  // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
1821
1917
  useEffect(() => {
@@ -2250,7 +2346,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2250
2346
  if (result.hotSession?.webglReady) {
2251
2347
  result.widget.props = { ...result.widget.props, webglReady: true }
2252
2348
  }
2253
- undoRedo.snapshot(stateRef.current, 'add')
2254
2349
  // Guard against duplicates: HMR may have already pushed the new widget
2255
2350
  // into localWidgets before the API response returns (race condition).
2256
2351
  setLocalWidgets((prev) => {
@@ -2259,6 +2354,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2259
2354
  return [...arr, result.widget]
2260
2355
  })
2261
2356
  setSelectedWidgetIds(new Set([result.widget.id]))
2357
+ if (result.eventId) undoRedo.track(result.eventId)
2262
2358
  }
2263
2359
  } catch (err) {
2264
2360
  console.error('[canvas] Failed to add widget:', err)
@@ -2277,13 +2373,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2277
2373
  position: pos,
2278
2374
  })
2279
2375
  if (result.success && result.widget) {
2280
- undoRedo.snapshot(stateRef.current, 'add')
2281
2376
  setLocalWidgets((prev) => {
2282
2377
  const arr = prev || []
2283
2378
  if (arr.some((w) => w.id === result.widget.id)) return arr
2284
2379
  return [...arr, result.widget]
2285
2380
  })
2286
2381
  setSelectedWidgetIds(new Set([result.widget.id]))
2382
+ if (result.eventId) undoRedo.track(result.eventId)
2287
2383
  }
2288
2384
  } catch (err) {
2289
2385
  console.error('[canvas] Failed to add story widget:', err)
@@ -2727,11 +2823,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2727
2823
  if (e.key === 'Delete' || e.key === 'Backspace') {
2728
2824
  e.preventDefault()
2729
2825
  if (selectedWidgetIds.size > 1) {
2730
- // Multi-delete — snapshot once, remove all via individual widget_removed
2731
- // events (not widgets_replaced) to avoid the server's wipe guard and
2826
+ // Multi-delete — remove all via individual widget_removed events
2827
+ // (not widgets_replaced) to avoid the server's wipe guard and
2732
2828
  // ensure terminal sessions are properly orphaned per widget.
2733
- undoRedo.snapshot(stateRef.current, 'multi-remove')
2734
- debouncedSave.cancel()
2829
+ cancelAllPendingWidgetPatches()
2735
2830
  const idsToRemove = new Set(selectedWidgetIds)
2736
2831
  // Mark deletions before mutating local state so the HMR merge
2737
2832
  // doesn't re-add them mid-flight.
@@ -2742,22 +2837,28 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2742
2837
  const orphaned = prev.filter((c) => idsToRemove.has(c.start.widgetId) || idsToRemove.has(c.end.widgetId))
2743
2838
  for (const c of orphaned) {
2744
2839
  markConnectorDeleted(c.id)
2745
- queueWrite(() =>
2746
- removeConnectorApi(canvasId, c.id).catch((err) =>
2840
+ queueWrite(async () => {
2841
+ try {
2842
+ const res = await removeConnectorApi(canvasId, c.id)
2843
+ if (res?.eventId) undoRedo.track(res.eventId)
2844
+ } catch (err) {
2747
2845
  console.error('[canvas] Failed to remove orphaned connector:', err)
2748
- )
2749
- )
2846
+ }
2847
+ })
2750
2848
  }
2751
2849
  return prev.filter((c) => !idsToRemove.has(c.start.widgetId) && !idsToRemove.has(c.end.widgetId))
2752
2850
  })
2753
2851
  dirtyRef.current = true
2754
2852
  // Queue individual delete API calls
2755
2853
  for (const widgetId of idsToRemove) {
2756
- queueWrite(() =>
2757
- removeWidgetApi(canvasId, widgetId).catch(err =>
2854
+ queueWrite(async () => {
2855
+ try {
2856
+ const res = await removeWidgetApi(canvasId, widgetId)
2857
+ if (res?.eventId) undoRedo.track(res.eventId)
2858
+ } catch (err) {
2758
2859
  console.error('[canvas] Failed to remove widget in multi-delete:', err)
2759
- )
2760
- )
2860
+ }
2861
+ })
2761
2862
  }
2762
2863
  } else {
2763
2864
  const widgetId = [...selectedWidgetIds][0]
@@ -2768,7 +2869,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2768
2869
  }
2769
2870
  document.addEventListener('keydown', handleKeyDown)
2770
2871
  return () => document.removeEventListener('keydown', handleKeyDown)
2771
- }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave, markWidgetDeleted, markConnectorDeleted])
2872
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, cancelAllPendingWidgetPatches, markWidgetDeleted, markConnectorDeleted])
2772
2873
 
2773
2874
  // Ref to store processImageFile for use by drop effect
2774
2875
  const processImageFileRef = useRef(null)
@@ -2838,10 +2939,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2838
2939
  position: pos,
2839
2940
  })
2840
2941
  if (result.success && result.widget) {
2841
- undoRedo.snapshot(stateRef.current, 'add')
2842
2942
  setLocalWidgets((prev) => [...(prev || []), result.widget])
2843
2943
  setSelectedWidgetIds(new Set([result.widget.id]))
2844
2944
  navigator.clipboard?.writeText(result.widget.id).catch(() => {})
2945
+ if (result.eventId) undoRedo.track(result.eventId)
2845
2946
  }
2846
2947
  return true
2847
2948
  } catch (err) {
@@ -2925,11 +3026,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2925
3026
  const baseX = Math.round(center.x - groupW / 2)
2926
3027
  const baseY = Math.round(center.y - groupH / 2)
2927
3028
 
2928
- // Single undo snapshot for the entire paste
2929
- undoRedo.snapshot(stateRef.current, 'add')
2930
-
2931
3029
  // Paste all widgets, collecting new IDs for selection
2932
3030
  const newWidgets = []
3031
+ const eventIds = []
2933
3032
  for (const w of sourceWidgets) {
2934
3033
  const relX = (w.position?.x ?? 0) - minX
2935
3034
  const relY = (w.position?.y ?? 0) - minY
@@ -2952,6 +3051,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2952
3051
  result.widget.props = { ...result.widget.props, webglReady: true }
2953
3052
  }
2954
3053
  newWidgets.push(result.widget)
3054
+ if (result.eventId) eventIds.push(result.eventId)
2955
3055
  }
2956
3056
  }
2957
3057
 
@@ -2959,6 +3059,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2959
3059
  setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
2960
3060
  setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
2961
3061
  }
3062
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
2962
3063
  } catch (err) {
2963
3064
  console.error('[canvas] Failed to paste widget reference:', err)
2964
3065
  }
@@ -3009,9 +3110,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3009
3110
  position: pos,
3010
3111
  })
3011
3112
  if (result.success && result.widget) {
3012
- undoRedo.snapshot(stateRef.current, 'add')
3013
3113
  setLocalWidgets((prev) => [...(prev || []), result.widget])
3014
3114
  setSelectedWidgetIds(new Set([result.widget.id]))
3115
+ if (result.eventId) undoRedo.track(result.eventId)
3015
3116
  }
3016
3117
  } catch (err) {
3017
3118
  console.error('[canvas] Failed to add widget from paste:', err)
@@ -3099,35 +3200,57 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3099
3200
  }, [loading])
3100
3201
 
3101
3202
  // --- Undo / Redo ---
3203
+ // Server-authoritative: POST /undo / POST /redo append an inverse event
3204
+ // to the JSONL. The HMR push from that write flows back through useCanvas
3205
+ // → setLocalWidgets, so we don't optimistically mutate local state here.
3206
+ //
3207
+ // 404 handling: if the server can't find the target event, it was almost
3208
+ // certainly compacted away (compaction collapses history into a single
3209
+ // canvas_created baseline). We clear the per-tab stack so the toolbar
3210
+ // button greys out instead of silently no-op'ing on every click.
3102
3211
  const handleUndo = useCallback(() => {
3103
- const previous = undoRedo.undo(stateRef.current)
3104
- if (!previous) return
3105
- debouncedSave.cancel()
3212
+ const eventId = undoRedo.popUndo()
3213
+ if (!eventId) return
3214
+ flushAllPendingWidgetPatches()
3106
3215
  debouncedSourceSave.cancel()
3107
3216
  dirtyRef.current = true
3108
- setLocalWidgets(previous.widgets)
3109
- setLocalSources(previous.sources)
3110
- setLocalConnectors(previous.connectors ?? [])
3111
- queueWrite(() =>
3112
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
3113
- .catch((err) => console.error('[canvas] Failed to persist undo:', err))
3114
- )
3115
- }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
3217
+ queueWrite(async () => {
3218
+ try {
3219
+ const { status, data } = await undoEventApi(canvasId, eventId)
3220
+ if (status === 404) {
3221
+ console.warn('[canvas] Undo target was compacted away resetting undo stack')
3222
+ undoRedo.reset()
3223
+ return
3224
+ }
3225
+ if (data?.eventId) undoRedo.pushRedo(data.eventId)
3226
+ else if (data?.error) console.error('[canvas] Undo rejected:', data.error)
3227
+ } catch (err) {
3228
+ console.error('[canvas] Failed to persist undo:', err)
3229
+ }
3230
+ })
3231
+ }, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
3116
3232
 
3117
3233
  const handleRedo = useCallback(() => {
3118
- const next = undoRedo.redo(stateRef.current)
3119
- if (!next) return
3120
- debouncedSave.cancel()
3234
+ const eventId = undoRedo.popRedo()
3235
+ if (!eventId) return
3236
+ flushAllPendingWidgetPatches()
3121
3237
  debouncedSourceSave.cancel()
3122
3238
  dirtyRef.current = true
3123
- setLocalWidgets(next.widgets)
3124
- setLocalSources(next.sources)
3125
- setLocalConnectors(next.connectors ?? [])
3126
- queueWrite(() =>
3127
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
3128
- .catch((err) => console.error('[canvas] Failed to persist redo:', err))
3129
- )
3130
- }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
3239
+ queueWrite(async () => {
3240
+ try {
3241
+ const { status, data } = await redoEventApi(canvasId, eventId)
3242
+ if (status === 404) {
3243
+ console.warn('[canvas] Redo target was compacted away resetting redo stack')
3244
+ undoRedo.reset()
3245
+ return
3246
+ }
3247
+ if (data?.eventId) undoRedo.pushUndo(data.eventId)
3248
+ else if (data?.error) console.error('[canvas] Redo rejected:', data.error)
3249
+ } catch (err) {
3250
+ console.error('[canvas] Failed to persist redo:', err)
3251
+ }
3252
+ })
3253
+ }, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
3131
3254
 
3132
3255
  // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
3133
3256
  useEffect(() => {