@dfosco/storyboard 0.7.0 → 0.8.1

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 (258) hide show
  1. package/assets/favicon.svg +12 -0
  2. package/dist/storyboard-ui.css +1 -1
  3. package/dist/storyboard-ui.js +13907 -10816
  4. package/dist/storyboard-ui.js.map +1 -1
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +138 -127
  7. package/scaffold/AGENTS.md +9 -7
  8. package/scaffold/README.md +18 -0
  9. package/scaffold/deploy.yml +21 -0
  10. package/scaffold/gitignore +8 -14
  11. package/scaffold/preview.yml +40 -0
  12. package/scaffold/{manifest.json → scaffold.config.json} +73 -3
  13. package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
  14. package/scaffold/skills/canvas/SKILL.md +1 -1
  15. package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
  16. package/scaffold/skills/ship/SKILL.md +2 -2
  17. package/scaffold/skills/worktree/SKILL.md +1 -1
  18. package/scaffold/src/_prototype.jsx +17 -0
  19. package/scaffold/src/_story.jsx +13 -0
  20. package/scaffold/src/library/README.md +34 -0
  21. package/scaffold/src/library/_app.jsx +53 -0
  22. package/scaffold/src/library/_app.module.css +8 -0
  23. package/scaffold/src/library/create.jsx +10 -0
  24. package/scaffold/src/library/index.jsx +14 -0
  25. package/scaffold/src/library/mount.jsx +52 -0
  26. package/scaffold/src/library/prototypes-entry.jsx +57 -0
  27. package/scaffold/src/library/routes.jsx +205 -0
  28. package/scaffold/src/library/viewfinder.jsx +13 -0
  29. package/scaffold/src/library/workspace.jsx +17 -0
  30. package/scripts/aggregate-artifacts.mjs +257 -0
  31. package/src/core/artifact/operations.js +19 -13
  32. package/src/core/autosync/server.test.js +1 -1
  33. package/src/core/canvas/githubEmbeds.js +12 -0
  34. package/src/core/canvas/githubEmbeds.test.js +71 -2
  35. package/src/core/canvas/hot-pool.js +10 -11
  36. package/src/core/canvas/materializer.js +39 -13
  37. package/src/core/canvas/server.js +324 -69
  38. package/src/core/canvas/server.test.js +365 -2
  39. package/src/core/canvas/terminal-server.js +144 -57
  40. package/src/core/canvas/undoRedo.js +174 -0
  41. package/src/core/canvas/undoRedo.test.js +250 -0
  42. package/src/core/cli/buildManifest.js +34 -0
  43. package/src/core/cli/canvasRedo.js +44 -0
  44. package/src/core/cli/canvasUndo.js +40 -0
  45. package/src/core/cli/create.js +116 -18
  46. package/src/core/cli/dev.js +23 -35
  47. package/src/core/cli/index.js +21 -0
  48. package/src/core/cli/schemas.js +2 -3
  49. package/src/core/cli/serverUrl.js +24 -0
  50. package/src/core/cli/setup.js +14 -25
  51. package/src/core/cli/terminal-welcome.js +12 -6
  52. package/src/core/cli/updateVersion.js +42 -2
  53. package/src/core/cli/workleaf.js +140 -0
  54. package/src/core/comments/api.js +1 -1
  55. package/src/core/comments/queries.js +3 -0
  56. package/src/core/comments/ui/CommentWindow.jsx +3 -2
  57. package/src/core/data/artifactIndex.js +235 -0
  58. package/src/core/data/artifactIndex.test.js +203 -0
  59. package/src/core/data/artifactManifest.js +290 -0
  60. package/src/core/data/artifactManifest.test.js +207 -0
  61. package/src/core/data/viewfinder.js +47 -7
  62. package/src/core/index.js +6 -1
  63. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
  64. package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
  65. package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
  66. package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
  67. package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
  68. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
  69. package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
  70. package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
  71. package/src/core/lib/components/ui/dialog/index.js +4 -34
  72. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
  73. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
  74. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
  75. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
  76. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
  77. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
  78. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
  79. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
  80. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
  81. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
  82. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
  83. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
  84. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
  85. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
  86. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
  87. package/src/core/lib/components/ui/label/label.jsx +1 -2
  88. package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
  89. package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
  90. package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
  91. package/src/core/lib/components/ui/panel/panel.jsx +11 -2
  92. package/src/core/lib/components/ui/separator/separator.jsx +6 -4
  93. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
  94. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
  95. package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
  96. package/src/core/mountStoryboardCore.js +234 -74
  97. package/src/core/mountStoryboardCore.test.js +174 -0
  98. package/src/core/rename-watcher/watcher.js +329 -36
  99. package/src/core/rename-watcher/watcher.test.js +253 -0
  100. package/src/core/scaffold/fragments.js +287 -0
  101. package/src/core/scaffold/fragments.test.js +401 -0
  102. package/src/core/scaffold/index.js +291 -0
  103. package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
  104. package/src/core/scaffold/scaffold-integration.test.js +247 -0
  105. package/src/core/scaffold.js +4 -97
  106. package/src/core/stores/configSchema.js +41 -2
  107. package/src/core/stores/recentArtifacts.js +49 -2
  108. package/src/core/stores/recentArtifacts.test.js +59 -6
  109. package/src/core/stores/themeBootstrap.js +264 -0
  110. package/src/core/stores/themeBootstrap.test.js +211 -0
  111. package/src/core/stores/themeStore.test.js +154 -0
  112. package/src/core/stores/themeStore.ts +235 -220
  113. package/src/core/styles/generateThemesCss.js +63 -0
  114. package/src/core/styles/tailwind.css +12 -4
  115. package/src/core/styles/workspace-chrome.css +30 -0
  116. package/src/core/tools/handlers/paletteTheme.js +11 -8
  117. package/src/core/ui/ActionMenuButton.jsx +2 -1
  118. package/src/core/ui/CanvasCreateMenu.jsx +1 -19
  119. package/src/core/ui/CoreUIBar.jsx +10 -7
  120. package/src/core/ui/ThemeMenuButton.jsx +45 -30
  121. package/src/core/vite/server-plugin.js +36 -0
  122. package/src/core/workleaf/cleanup.js +102 -0
  123. package/src/core/workleaf/create.js +199 -0
  124. package/src/core/workleaf/delete.js +101 -0
  125. package/src/core/workleaf/list.js +57 -0
  126. package/src/core/workleaf/merge.js +238 -0
  127. package/src/core/workleaf/paths.js +146 -0
  128. package/src/core/workleaf/paths.test.js +230 -0
  129. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +28 -21
  130. package/src/core/workshop/features/createFlow/server.js +5 -1
  131. package/src/core/workshop/features/createPrototype/server.js +5 -1
  132. package/src/core/workshop/features/templateIndex.js +28 -1
  133. package/src/core/worktree/port.js +48 -5
  134. package/src/core/worktree/port.test.js +42 -0
  135. package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
  136. package/src/internals/ArtifactForm/ArtifactForm.module.css +48 -66
  137. package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
  138. package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
  139. package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
  140. package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
  141. package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
  142. package/src/internals/CommandPalette/CommandPalette.jsx +25 -19
  143. package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
  144. package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
  145. package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
  146. package/src/internals/CommandPalette/createHelpers.js +30 -0
  147. package/src/internals/CreatePage/CreatePage.jsx +57 -0
  148. package/src/internals/CreatePage/CreatePage.module.css +34 -0
  149. package/src/internals/Dialog.jsx +51 -0
  150. package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +533 -0
  151. package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
  152. package/src/internals/Tooltip.jsx +39 -0
  153. package/src/internals/Viewfinder.jsx +196 -48
  154. package/src/internals/Viewfinder.module.css +72 -1
  155. package/src/internals/canvas/CanvasPage.jsx +267 -124
  156. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
  157. package/src/internals/canvas/PageSelector.jsx +15 -1
  158. package/src/internals/canvas/PageSelector.module.css +14 -0
  159. package/src/internals/canvas/canvasApi.js +40 -5
  160. package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
  161. package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
  162. package/src/internals/canvas/isolation/prototypeRoutes.jsx +131 -0
  163. package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
  164. package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
  165. package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
  166. package/src/internals/canvas/useUndoRedo.js +75 -60
  167. package/src/internals/canvas/useUndoRedo.test.js +46 -178
  168. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
  169. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
  170. package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
  171. package/src/internals/canvas/widgets/LinkPreview.jsx +45 -1
  172. package/src/internals/canvas/widgets/LinkPreview.module.css +71 -0
  173. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +69 -10
  174. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +114 -0
  175. package/src/internals/canvas/widgets/StorySetWidget.jsx +8 -4
  176. package/src/internals/canvas/widgets/StoryWidget.jsx +21 -38
  177. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
  178. package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
  179. package/src/internals/canvas/widgets/WidgetChrome.module.css +15 -0
  180. package/src/internals/canvas/widgets/embedTheme.js +2 -2
  181. package/src/internals/canvas/widgets/expandUtils.js +19 -19
  182. package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
  183. package/src/internals/context.jsx +22 -1
  184. package/src/internals/context.test.jsx +47 -0
  185. package/src/internals/hooks/useArtifactIndex.js +31 -0
  186. package/src/internals/hooks/useArtifactIndex.test.js +45 -0
  187. package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
  188. package/src/internals/index.js +6 -0
  189. package/src/internals/loadConsumerWrapper.js +60 -0
  190. package/src/internals/story/ComponentSetPage.jsx +75 -34
  191. package/src/internals/story/StoryPage.jsx +16 -25
  192. package/src/internals/vite/data-plugin.js +313 -148
  193. package/src/internals/vite/data-plugin.test.js +11 -11
  194. package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
  195. package/terminal.config.json +1 -1
  196. package/src/core/lib/components/ui/card/card-action.jsx +0 -11
  197. package/src/core/lib/components/ui/card/card-content.jsx +0 -11
  198. package/src/core/lib/components/ui/card/card-description.jsx +0 -11
  199. package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
  200. package/src/core/lib/components/ui/card/card-header.jsx +0 -19
  201. package/src/core/lib/components/ui/card/card-title.jsx +0 -11
  202. package/src/core/lib/components/ui/card/card.jsx +0 -17
  203. package/src/core/lib/components/ui/card/index.js +0 -25
  204. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
  205. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
  206. package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
  207. package/src/core/lib/components/ui/collapsible/index.js +0 -13
  208. package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
  209. package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
  210. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
  211. package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
  212. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
  213. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
  214. package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
  215. package/src/core/lib/components/ui/popover/index.js +0 -28
  216. package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
  217. package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
  218. package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
  219. package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
  220. package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
  221. package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
  222. package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
  223. package/src/core/lib/components/ui/popover/popover.jsx +0 -4
  224. package/src/core/lib/components/ui/select/index.js +0 -37
  225. package/src/core/lib/components/ui/select/select-content.jsx +0 -30
  226. package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
  227. package/src/core/lib/components/ui/select/select-group.jsx +0 -15
  228. package/src/core/lib/components/ui/select/select-item.jsx +0 -26
  229. package/src/core/lib/components/ui/select/select-label.jsx +0 -11
  230. package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
  231. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
  232. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
  233. package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
  234. package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
  235. package/src/core/lib/components/ui/select/select.jsx +0 -4
  236. package/src/core/lib/components/ui/sheet/index.js +0 -34
  237. package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
  238. package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
  239. package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
  240. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
  241. package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
  242. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
  243. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
  244. package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
  245. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
  246. package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
  247. package/src/core/lib/components/ui/toggle/index.js +0 -8
  248. package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
  249. package/src/core/lib/components/ui/toggle-group/index.js +0 -10
  250. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
  251. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
  252. package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
  253. package/src/internals/canvas/componentIsolate.jsx +0 -144
  254. package/src/internals/canvas/componentSetIsolate.jsx +0 -300
  255. package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
  256. package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
  257. package/src/primer/ThemeSync.jsx +0 -73
  258. package/src/primer/index.js +0 -1
@@ -12,6 +12,7 @@ import { getPasteRules } from '../../core/index.js'
12
12
  import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
13
13
  import { getFlag, subscribeToStorage } from '../../core/index.js'
14
14
  import { getCanvasZoom } from '../../core/index.js'
15
+ import { trackRecent as trackRecentArtifact } from '../../core/index.js'
15
16
  import { registerSmoothCorners } from '../../core/utils/smoothCorners.js'
16
17
  import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
17
18
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
@@ -28,7 +29,10 @@ import {
28
29
  duplicateImage,
29
30
  fetchGitHubEmbed,
30
31
  getCanvas as getCanvasApi,
32
+ patchWidget as patchWidgetApi,
31
33
  removeWidget as removeWidgetApi,
34
+ redoEvent as redoEventApi,
35
+ undoEvent as undoEventApi,
32
36
  updateCanvas,
33
37
  updateFolderMeta,
34
38
  uploadImage,
@@ -732,6 +736,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
732
736
  return () => { cancelled = true }
733
737
  }, [canvasId])
734
738
 
739
+ // Track this canvas as a recent visit so it appears in the workspace
740
+ // Recent tab and the command palette regardless of how the user arrived
741
+ // (card click, command palette, direct URL, link from another canvas, …).
742
+ useEffect(() => {
743
+ if (!canvasId) return
744
+ const label = canvasMeta?.title || canvas?.title || name || canvasId.split('/').pop() || canvasId
745
+ trackRecentArtifact('canvas', canvasId, label)
746
+ }, [canvasId, canvasMeta, canvas, name])
747
+
735
748
  // Centralized list of component export names.
736
749
  // When jsxExports is available, use it (discovers new exports not yet in sources).
737
750
  // When jsxExports is null (module import failed), fall back to sources so iframes
@@ -950,27 +963,66 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
950
963
  }
951
964
  }
952
965
 
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
966
+ // Per-widget debounced PATCH. Each widget gets its own pending props
967
+ // accumulator + timer so concurrent edits to different widgets don't stomp
968
+ // on one another. Successful patches return event ids — we push them onto
969
+ // the undo stack via undoRedo.trackMany so the toolbar Undo button targets
970
+ // exactly the right granular events.
971
+ const widgetPatchDebouncersRef = useRef(new Map())
972
+ const flushAllPendingWidgetPatches = useCallback(() => {
973
+ for (const entry of widgetPatchDebouncersRef.current.values()) {
974
+ if (entry.timer) {
975
+ clearTimeout(entry.timer)
976
+ entry.timer = null
977
+ entry.flushNow()
978
+ }
979
+ }
980
+ }, [])
981
+ const cancelAllPendingWidgetPatches = useCallback(() => {
982
+ for (const entry of widgetPatchDebouncersRef.current.values()) {
983
+ if (entry.timer) {
984
+ clearTimeout(entry.timer)
985
+ entry.timer = null
986
+ entry.pendingProps = {}
987
+ }
988
+ }
989
+ }, [])
990
+ // patchWidgetDebounced(canvasId, widgetId, propsDelta) — coalesces multiple
991
+ // PATCHes per widget within DEBOUNCE_MS into a single request.
992
+ const patchWidgetDebounced = useCallback((targetCanvasId, widgetId, propsDelta) => {
993
+ if (!widgetId || !propsDelta) return
994
+ const map = widgetPatchDebouncersRef.current
995
+ let entry = map.get(widgetId)
996
+ if (!entry) {
997
+ entry = {
998
+ pendingProps: {},
999
+ timer: null,
1000
+ flushNow: () => {
1001
+ const props = entry.pendingProps
1002
+ entry.pendingProps = {}
1003
+ if (Object.keys(props).length === 0) return
1004
+ dirtyRef.current = true
1005
+ queueWrite(async () => {
1006
+ try {
1007
+ const res = await patchWidgetApi(targetCanvasId, widgetId, { props })
1008
+ if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
1009
+ } catch (err) {
1010
+ console.error('[canvas] Failed to patch widget:', err)
1011
+ }
1012
+ })
1013
+ },
1014
+ }
1015
+ map.set(widgetId, entry)
1016
+ }
1017
+ Object.assign(entry.pendingProps, propsDelta)
1018
+ if (entry.timer) clearTimeout(entry.timer)
1019
+ entry.timer = setTimeout(() => {
1020
+ entry.timer = null
1021
+ entry.flushNow()
1022
+ }, 500)
1023
+ }, [undoRedo])
971
1024
 
972
1025
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
973
- undoRedo.snapshot(stateRef.current, 'edit', widgetId)
974
1026
  // Snap width/height to grid when snap is enabled
975
1027
  const snapped = { ...updates }
976
1028
  if (snapEnabled && snapGridSize) {
@@ -985,11 +1037,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
985
1037
  const next = prev.map((w) =>
986
1038
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
987
1039
  )
988
- dirtyRef.current = true
989
- debouncedSave(canvasId, next)
990
1040
  return next
991
1041
  })
992
- }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
1042
+ dirtyRef.current = true
1043
+ patchWidgetDebounced(canvasId, widgetId, snapped)
1044
+ }, [canvasId, patchWidgetDebounced, snapEnabled, snapGridSize])
993
1045
 
994
1046
  // Stable ref so handleWidgetSelect can flip a "done" agent back to
995
1047
  // "running" without re-creating itself on every widget update.
@@ -1002,35 +1054,60 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1002
1054
  const defaultMeta = hubRoleOptions.find((r) => r.id === defaultHubRole) || null
1003
1055
  const fallbackRoleId = defaultMeta?.id || 'member'
1004
1056
 
1005
- undoRedo.snapshot(stateRef.current, 'edit-role', widgetId)
1006
1057
  setLocalWidgets((prev) => {
1007
1058
  if (!prev) return prev
1008
1059
 
1009
1060
  const componentIds = getConnectedComponent(widgetId, localConnectors)
1010
1061
  const scopeHas = (id) => componentIds.has(id)
1062
+ const roleChanges = [] // { widgetId, role }
1011
1063
  const next = prev.map((widget) => {
1012
1064
  if (widget.id === widgetId) {
1065
+ roleChanges.push({ widgetId: widget.id, role: targetRole })
1013
1066
  return { ...widget, props: { ...widget.props, role: targetRole } }
1014
1067
  }
1015
1068
  if (!roleMeta || roleMeta.type !== 'unique') return widget
1016
1069
  if (widget.type !== 'agent' && widget.type !== 'prompt') return widget
1017
1070
  if (!scopeHas(widget.id)) return widget
1018
1071
  if ((widget.props?.role || fallbackRoleId) !== targetRole) return widget
1072
+ roleChanges.push({ widgetId: widget.id, role: fallbackRoleId })
1019
1073
  return { ...widget, props: { ...widget.props, role: fallbackRoleId } }
1020
1074
  })
1021
1075
 
1022
1076
  dirtyRef.current = true
1023
- debouncedSave(canvasId, next)
1077
+ // Persist as a single batch so undo restores all role flips atomically.
1078
+ if (roleChanges.length > 0) {
1079
+ const operations = roleChanges.map((c) => ({
1080
+ op: 'update-widget',
1081
+ widgetId: c.widgetId,
1082
+ props: { role: c.role },
1083
+ }))
1084
+ queueWrite(async () => {
1085
+ try {
1086
+ const res = await batchOperations(canvasId, operations)
1087
+ const ids = (res?.results || [])
1088
+ .map((r) => r?.eventId)
1089
+ .filter(Boolean)
1090
+ if (ids.length > 0) undoRedo.trackMany(ids)
1091
+ } catch (err) {
1092
+ console.error('[canvas] Failed to persist role change:', err)
1093
+ }
1094
+ })
1095
+ }
1024
1096
  return next
1025
1097
  })
1026
- }, [canvasId, debouncedSave, undoRedo, defaultHubRole, hubRoleOptions, localConnectors])
1098
+ }, [canvasId, undoRedo, defaultHubRole, hubRoleOptions, localConnectors])
1027
1099
 
1028
1100
  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()
1101
+ // Cancel any pending widget patches that target this widget they
1102
+ // would race against the delete and leave a stale widget_updated event
1103
+ // in the JSONL.
1104
+ const debouncer = widgetPatchDebouncersRef.current.get(widgetId)
1105
+ if (debouncer?.timer) {
1106
+ clearTimeout(debouncer.timer)
1107
+ debouncer.timer = null
1108
+ debouncer.pendingProps = {}
1109
+ }
1032
1110
 
1033
- undoRedo.snapshot(stateRef.current, 'remove', widgetId)
1034
1111
  markWidgetDeleted(widgetId)
1035
1112
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
1036
1113
  // Cascade: remove connectors referencing this widget
@@ -1039,27 +1116,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1039
1116
  if (orphaned.length === 0) return prev
1040
1117
  for (const c of orphaned) {
1041
1118
  markConnectorDeleted(c.id)
1042
- queueWrite(() =>
1043
- removeConnectorApi(canvasId, c.id).catch((err) =>
1119
+ queueWrite(async () => {
1120
+ try {
1121
+ const res = await removeConnectorApi(canvasId, c.id)
1122
+ if (res?.eventId) undoRedo.track(res.eventId)
1123
+ } catch (err) {
1044
1124
  console.error('[canvas] Failed to remove orphaned connector:', err)
1045
- )
1046
- )
1125
+ }
1126
+ })
1047
1127
  }
1048
1128
  return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
1049
1129
  })
1050
1130
  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])
1131
+ queueWrite(async () => {
1132
+ try {
1133
+ const res = await removeWidgetApi(canvasId, widgetId)
1134
+ if (res?.eventId) undoRedo.track(res.eventId)
1135
+ } catch (err) {
1136
+ console.error('[canvas] Failed to remove widget:', err)
1137
+ }
1138
+ })
1139
+ }, [canvasId, undoRedo, markWidgetDeleted, markConnectorDeleted])
1056
1140
 
1057
1141
  const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
1058
1142
  try {
1059
- undoRedo.snapshot(stateRef.current, 'connector-add')
1060
1143
  const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
1061
1144
  if (result.success && result.connector) {
1062
1145
  setLocalConnectors((prev) => [...prev, result.connector])
1146
+ if (result.eventId) undoRedo.track(result.eventId)
1063
1147
  }
1064
1148
  } catch (err) {
1065
1149
  console.error('[canvas] Failed to add connector:', err)
@@ -1099,15 +1183,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1099
1183
  }, [handleConnectorAdd])
1100
1184
 
1101
1185
  const handleConnectorRemove = useCallback((connectorId) => {
1102
- undoRedo.snapshot(stateRef.current, 'connector-remove')
1103
1186
  markConnectorDeleted(connectorId)
1104
1187
  setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
1105
1188
  dirtyRef.current = true
1106
- queueWrite(() =>
1107
- removeConnectorApi(canvasId, connectorId).catch((err) =>
1189
+ queueWrite(async () => {
1190
+ try {
1191
+ const res = await removeConnectorApi(canvasId, connectorId)
1192
+ if (res?.eventId) undoRedo.track(res.eventId)
1193
+ } catch (err) {
1108
1194
  console.error('[canvas] Failed to remove connector:', err)
1109
- )
1110
- )
1195
+ }
1196
+ })
1111
1197
  }, [canvasId, undoRedo, markConnectorDeleted])
1112
1198
 
1113
1199
  // Connector drag state
@@ -1293,7 +1379,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1293
1379
  if (dupResult.success) copyProps.src = dupResult.filename
1294
1380
  }
1295
1381
 
1296
- undoRedo.snapshot(stateRef.current, 'add')
1297
1382
  const result = await addWidgetApi(canvasId, {
1298
1383
  type: widget.type,
1299
1384
  props: copyProps,
@@ -1305,6 +1390,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1305
1390
  }
1306
1391
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1307
1392
  setSelectedWidgetIds(new Set([result.widget.id]))
1393
+ if (result.eventId) undoRedo.track(result.eventId)
1308
1394
  }
1309
1395
  } catch (err) {
1310
1396
  console.error('[canvas] Failed to copy widget:', err)
@@ -1316,8 +1402,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1316
1402
  if (!widget) return
1317
1403
  const widgets = [widget]
1318
1404
 
1319
- undoRedo.snapshot(stateRef.current, 'add')
1320
-
1321
1405
  const occupied = new Set(
1322
1406
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1323
1407
  )
@@ -1378,8 +1462,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1378
1462
  const newWidgets = []
1379
1463
  const newConnectors = []
1380
1464
  const refMap = response.refs || {}
1465
+ const eventIds = []
1381
1466
 
1382
1467
  for (const result of response.results) {
1468
+ if (result.eventId) eventIds.push(result.eventId)
1383
1469
  if (result.op === 'create-widget' && result.widget) {
1384
1470
  newWidgets.push(result.widget)
1385
1471
  }
@@ -1409,6 +1495,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1409
1495
  if (newConnectors.length > 0) {
1410
1496
  setLocalConnectors((prev) => [...prev, ...newConnectors])
1411
1497
  }
1498
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
1412
1499
  } catch (err) {
1413
1500
  console.error('[canvas] Failed to duplicate with connectors:', err)
1414
1501
  }
@@ -1419,9 +1506,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1419
1506
  const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1420
1507
  if (widgets.length === 0) return
1421
1508
 
1422
- // Single undo snapshot for the entire batch
1423
- undoRedo.snapshot(stateRef.current, 'add')
1424
-
1425
1509
  // Compute occupied positions to find free offset
1426
1510
  const occupied = new Set(
1427
1511
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
@@ -1435,6 +1519,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1435
1519
  while (anyOccupied()) offset++
1436
1520
 
1437
1521
  const newWidgets = []
1522
+ const eventIds = []
1438
1523
  for (const widget of widgets) {
1439
1524
  const position = {
1440
1525
  x: (widget.position?.x ?? 0) + offset * 40,
@@ -1460,6 +1545,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1460
1545
  result.widget.props = { ...result.widget.props, webglReady: true }
1461
1546
  }
1462
1547
  newWidgets.push(result.widget)
1548
+ if (result.eventId) eventIds.push(result.eventId)
1463
1549
  }
1464
1550
  } catch (err) {
1465
1551
  console.error('[canvas] Failed to duplicate widget:', err)
@@ -1470,6 +1556,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1470
1556
  setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1471
1557
  setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1472
1558
  }
1559
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
1473
1560
  }, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
1474
1561
 
1475
1562
  // Duplicate selected widgets WITH connectors (Cmd+Shift+D)
@@ -1479,8 +1566,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1479
1566
  const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1480
1567
  if (widgets.length === 0) return
1481
1568
 
1482
- undoRedo.snapshot(stateRef.current, 'add')
1483
-
1484
1569
  // Compute offset — same logic as handleDuplicateSelected
1485
1570
  const occupied = new Set(
1486
1571
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
@@ -1558,8 +1643,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1558
1643
  const newWidgets = []
1559
1644
  const newConnectors = []
1560
1645
  const refMap = response.refs || {}
1646
+ const eventIds = []
1561
1647
 
1562
1648
  for (const result of response.results) {
1649
+ if (result.eventId) eventIds.push(result.eventId)
1563
1650
  if (result.op === 'create-widget' && result.widget) {
1564
1651
  newWidgets.push(result.widget)
1565
1652
  }
@@ -1590,6 +1677,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1590
1677
  if (newConnectors.length > 0) {
1591
1678
  setLocalConnectors((prev) => [...prev, ...newConnectors])
1592
1679
  }
1680
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
1593
1681
  } catch (err) {
1594
1682
  console.error('[canvas] Failed to duplicate with connectors:', err)
1595
1683
  }
@@ -1636,6 +1724,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1636
1724
  : [],
1637
1725
  createdAt: snapshot.createdAt ?? null,
1638
1726
  updatedAt: snapshot.updatedAt ?? null,
1727
+ // PR-specific metadata (state, branches, diff stats)
1728
+ state: snapshot.state ?? null,
1729
+ merged: typeof snapshot.merged === 'boolean' ? snapshot.merged : null,
1730
+ draft: typeof snapshot.draft === 'boolean' ? snapshot.draft : null,
1731
+ baseRef: typeof snapshot.baseRef === 'string' ? snapshot.baseRef : null,
1732
+ headRef: typeof snapshot.headRef === 'string' ? snapshot.headRef : null,
1733
+ additions: typeof snapshot.additions === 'number' ? snapshot.additions : null,
1734
+ deletions: typeof snapshot.deletions === 'number' ? snapshot.deletions : null,
1735
+ changedFiles: typeof snapshot.changedFiles === 'number' ? snapshot.changedFiles : null,
1639
1736
  fetchedAt: new Date().toISOString(),
1640
1737
  },
1641
1738
  }
@@ -1654,17 +1751,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1654
1751
  }, [buildGitHubPreviewUpdates, handleWidgetUpdate])
1655
1752
 
1656
1753
  const debouncedSourceSave = useRef(
1657
- debounce((canvasId, sources) => {
1658
- queueWrite(() =>
1659
- updateCanvas(canvasId, { sources }).catch((err) =>
1754
+ debounce((canvasId, sources, onResult) => {
1755
+ queueWrite(async () => {
1756
+ try {
1757
+ const res = await updateCanvas(canvasId, { sources })
1758
+ if (typeof onResult === 'function') onResult(res)
1759
+ } catch (err) {
1660
1760
  console.error('[canvas] Failed to save sources:', err)
1661
- )
1662
- )
1761
+ }
1762
+ })
1663
1763
  }, 2000)
1664
1764
  ).current
1665
1765
 
1666
1766
  const handleSourceUpdate = useCallback((exportName, updates) => {
1667
- undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
1668
1767
  const snapped = { ...updates }
1669
1768
  if (snapEnabled && snapGridSize) {
1670
1769
  if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
@@ -1679,7 +1778,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1679
1778
  debouncedSourceSave(canvasId, next)
1680
1779
  return next
1681
1780
  })
1682
- }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
1781
+ }, [canvasId, debouncedSourceSave, snapEnabled, snapGridSize])
1683
1782
 
1684
1783
  const handleItemDragEnd = useCallback((dragId, position) => {
1685
1784
  setWidgetDragging(false)
@@ -1697,7 +1796,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1697
1796
  // Suppress the click-based selection reset that fires after pointerup
1698
1797
  justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
1699
1798
  requestAnimationFrame(() => { justDraggedRef.current = false })
1700
- undoRedo.snapshot(stateRef.current, 'multi-move')
1701
1799
 
1702
1800
  // Compute delta from the dragged widget's old position
1703
1801
  const isJsx = dragId.startsWith('jsx-')
@@ -1713,33 +1811,46 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1713
1811
  const dx = rounded.x - oldPos.x
1714
1812
  const dy = rounded.y - oldPos.y
1715
1813
 
1716
- debouncedSave.cancel()
1814
+ cancelAllPendingWidgetPatches()
1717
1815
 
1718
- // Update JSON widget positions
1816
+ // Update JSON widget positions. We collect move ops inside the setState
1817
+ // updater (which runs during commit) and fire the batch from there to
1818
+ // avoid the "set before commit" timing trap.
1719
1819
  setLocalWidgets((prev) => {
1720
1820
  if (!prev) return prev
1821
+ const widgetMoveOps = []
1721
1822
  const next = prev.map((w) => {
1722
- if (w.id === dragId) return { ...w, position: rounded }
1823
+ if (w.id === dragId) {
1824
+ widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: rounded })
1825
+ return { ...w, position: rounded }
1826
+ }
1723
1827
  if (ids.has(w.id)) {
1724
- return {
1725
- ...w,
1726
- position: {
1727
- x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
1728
- y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
1729
- },
1828
+ const newPos = {
1829
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
1830
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
1730
1831
  }
1832
+ widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: newPos })
1833
+ return { ...w, position: newPos }
1731
1834
  }
1732
1835
  return w
1733
1836
  })
1734
1837
  dirtyRef.current = true
1735
- queueWrite(() =>
1736
- updateCanvas(canvasId, { widgets: next })
1737
- .catch((err) => console.error('[canvas] Failed to save multi-move:', err))
1738
- )
1838
+ if (widgetMoveOps.length > 0) {
1839
+ queueWrite(async () => {
1840
+ try {
1841
+ const res = await batchOperations(canvasId, widgetMoveOps)
1842
+ const evIds = (res?.results || []).map((r) => r?.eventId).filter(Boolean)
1843
+ if (evIds.length > 0) undoRedo.trackMany(evIds)
1844
+ } catch (err) {
1845
+ console.error('[canvas] Failed to save multi-move:', err)
1846
+ }
1847
+ })
1848
+ }
1739
1849
  return next
1740
1850
  })
1741
1851
 
1742
- // Update JSX source positions
1852
+ // Update JSX source positions — still a single source_updated event;
1853
+ // not yet wired to per-source undo tracking.
1743
1854
  setLocalSources((prev) => {
1744
1855
  const current = Array.isArray(prev) ? prev : []
1745
1856
  let changed = false
@@ -1775,7 +1886,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1775
1886
  }
1776
1887
 
1777
1888
  if (dragId.startsWith('jsx-')) {
1778
- undoRedo.snapshot(stateRef.current, 'move', dragId)
1779
1889
  const sourceExport = dragId.replace(/^jsx-/, '')
1780
1890
  setLocalSources((prev) => {
1781
1891
  const current = Array.isArray(prev) ? prev : []
@@ -1792,21 +1902,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1792
1902
  return
1793
1903
  }
1794
1904
 
1795
- undoRedo.snapshot(stateRef.current, 'move', dragId)
1796
- debouncedSave.cancel()
1905
+ // Single-widget move — granular PATCH /widget so the move is its own
1906
+ // undoable event.
1907
+ cancelAllPendingWidgetPatches()
1797
1908
  setLocalWidgets((prev) => {
1798
1909
  if (!prev) return prev
1799
1910
  const next = prev.map((w) =>
1800
1911
  w.id === dragId ? { ...w, position: rounded } : w
1801
1912
  )
1802
1913
  dirtyRef.current = true
1803
- queueWrite(() =>
1804
- updateCanvas(canvasId, { widgets: next })
1805
- .catch((err) => console.error('[canvas] Failed to save widget position:', err))
1806
- )
1807
1914
  return next
1808
1915
  })
1809
- }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
1916
+ queueWrite(async () => {
1917
+ try {
1918
+ const res = await patchWidgetApi(canvasId, dragId, { position: rounded })
1919
+ if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
1920
+ } catch (err) {
1921
+ console.error('[canvas] Failed to save widget position:', err)
1922
+ }
1923
+ })
1924
+ }, [canvasId, undoRedo, cancelAllPendingWidgetPatches, transitionPeers, clearDragPreview])
1810
1925
 
1811
1926
  // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
1812
1927
  useEffect(() => {
@@ -2241,7 +2356,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2241
2356
  if (result.hotSession?.webglReady) {
2242
2357
  result.widget.props = { ...result.widget.props, webglReady: true }
2243
2358
  }
2244
- undoRedo.snapshot(stateRef.current, 'add')
2245
2359
  // Guard against duplicates: HMR may have already pushed the new widget
2246
2360
  // into localWidgets before the API response returns (race condition).
2247
2361
  setLocalWidgets((prev) => {
@@ -2250,6 +2364,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2250
2364
  return [...arr, result.widget]
2251
2365
  })
2252
2366
  setSelectedWidgetIds(new Set([result.widget.id]))
2367
+ if (result.eventId) undoRedo.track(result.eventId)
2253
2368
  }
2254
2369
  } catch (err) {
2255
2370
  console.error('[canvas] Failed to add widget:', err)
@@ -2268,13 +2383,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2268
2383
  position: pos,
2269
2384
  })
2270
2385
  if (result.success && result.widget) {
2271
- undoRedo.snapshot(stateRef.current, 'add')
2272
2386
  setLocalWidgets((prev) => {
2273
2387
  const arr = prev || []
2274
2388
  if (arr.some((w) => w.id === result.widget.id)) return arr
2275
2389
  return [...arr, result.widget]
2276
2390
  })
2277
2391
  setSelectedWidgetIds(new Set([result.widget.id]))
2392
+ if (result.eventId) undoRedo.track(result.eventId)
2278
2393
  }
2279
2394
  } catch (err) {
2280
2395
  console.error('[canvas] Failed to add story widget:', err)
@@ -2718,11 +2833,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2718
2833
  if (e.key === 'Delete' || e.key === 'Backspace') {
2719
2834
  e.preventDefault()
2720
2835
  if (selectedWidgetIds.size > 1) {
2721
- // Multi-delete — snapshot once, remove all via individual widget_removed
2722
- // events (not widgets_replaced) to avoid the server's wipe guard and
2836
+ // Multi-delete — remove all via individual widget_removed events
2837
+ // (not widgets_replaced) to avoid the server's wipe guard and
2723
2838
  // ensure terminal sessions are properly orphaned per widget.
2724
- undoRedo.snapshot(stateRef.current, 'multi-remove')
2725
- debouncedSave.cancel()
2839
+ cancelAllPendingWidgetPatches()
2726
2840
  const idsToRemove = new Set(selectedWidgetIds)
2727
2841
  // Mark deletions before mutating local state so the HMR merge
2728
2842
  // doesn't re-add them mid-flight.
@@ -2733,22 +2847,28 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2733
2847
  const orphaned = prev.filter((c) => idsToRemove.has(c.start.widgetId) || idsToRemove.has(c.end.widgetId))
2734
2848
  for (const c of orphaned) {
2735
2849
  markConnectorDeleted(c.id)
2736
- queueWrite(() =>
2737
- removeConnectorApi(canvasId, c.id).catch((err) =>
2850
+ queueWrite(async () => {
2851
+ try {
2852
+ const res = await removeConnectorApi(canvasId, c.id)
2853
+ if (res?.eventId) undoRedo.track(res.eventId)
2854
+ } catch (err) {
2738
2855
  console.error('[canvas] Failed to remove orphaned connector:', err)
2739
- )
2740
- )
2856
+ }
2857
+ })
2741
2858
  }
2742
2859
  return prev.filter((c) => !idsToRemove.has(c.start.widgetId) && !idsToRemove.has(c.end.widgetId))
2743
2860
  })
2744
2861
  dirtyRef.current = true
2745
2862
  // Queue individual delete API calls
2746
2863
  for (const widgetId of idsToRemove) {
2747
- queueWrite(() =>
2748
- removeWidgetApi(canvasId, widgetId).catch(err =>
2864
+ queueWrite(async () => {
2865
+ try {
2866
+ const res = await removeWidgetApi(canvasId, widgetId)
2867
+ if (res?.eventId) undoRedo.track(res.eventId)
2868
+ } catch (err) {
2749
2869
  console.error('[canvas] Failed to remove widget in multi-delete:', err)
2750
- )
2751
- )
2870
+ }
2871
+ })
2752
2872
  }
2753
2873
  } else {
2754
2874
  const widgetId = [...selectedWidgetIds][0]
@@ -2759,7 +2879,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2759
2879
  }
2760
2880
  document.addEventListener('keydown', handleKeyDown)
2761
2881
  return () => document.removeEventListener('keydown', handleKeyDown)
2762
- }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave, markWidgetDeleted, markConnectorDeleted])
2882
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, cancelAllPendingWidgetPatches, markWidgetDeleted, markConnectorDeleted])
2763
2883
 
2764
2884
  // Ref to store processImageFile for use by drop effect
2765
2885
  const processImageFileRef = useRef(null)
@@ -2829,10 +2949,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2829
2949
  position: pos,
2830
2950
  })
2831
2951
  if (result.success && result.widget) {
2832
- undoRedo.snapshot(stateRef.current, 'add')
2833
2952
  setLocalWidgets((prev) => [...(prev || []), result.widget])
2834
2953
  setSelectedWidgetIds(new Set([result.widget.id]))
2835
2954
  navigator.clipboard?.writeText(result.widget.id).catch(() => {})
2955
+ if (result.eventId) undoRedo.track(result.eventId)
2836
2956
  }
2837
2957
  return true
2838
2958
  } catch (err) {
@@ -2916,11 +3036,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2916
3036
  const baseX = Math.round(center.x - groupW / 2)
2917
3037
  const baseY = Math.round(center.y - groupH / 2)
2918
3038
 
2919
- // Single undo snapshot for the entire paste
2920
- undoRedo.snapshot(stateRef.current, 'add')
2921
-
2922
3039
  // Paste all widgets, collecting new IDs for selection
2923
3040
  const newWidgets = []
3041
+ const eventIds = []
2924
3042
  for (const w of sourceWidgets) {
2925
3043
  const relX = (w.position?.x ?? 0) - minX
2926
3044
  const relY = (w.position?.y ?? 0) - minY
@@ -2943,6 +3061,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2943
3061
  result.widget.props = { ...result.widget.props, webglReady: true }
2944
3062
  }
2945
3063
  newWidgets.push(result.widget)
3064
+ if (result.eventId) eventIds.push(result.eventId)
2946
3065
  }
2947
3066
  }
2948
3067
 
@@ -2950,6 +3069,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2950
3069
  setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
2951
3070
  setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
2952
3071
  }
3072
+ if (eventIds.length > 0) undoRedo.trackMany(eventIds)
2953
3073
  } catch (err) {
2954
3074
  console.error('[canvas] Failed to paste widget reference:', err)
2955
3075
  }
@@ -3000,9 +3120,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3000
3120
  position: pos,
3001
3121
  })
3002
3122
  if (result.success && result.widget) {
3003
- undoRedo.snapshot(stateRef.current, 'add')
3004
3123
  setLocalWidgets((prev) => [...(prev || []), result.widget])
3005
3124
  setSelectedWidgetIds(new Set([result.widget.id]))
3125
+ if (result.eventId) undoRedo.track(result.eventId)
3006
3126
  }
3007
3127
  } catch (err) {
3008
3128
  console.error('[canvas] Failed to add widget from paste:', err)
@@ -3090,35 +3210,57 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3090
3210
  }, [loading])
3091
3211
 
3092
3212
  // --- Undo / Redo ---
3213
+ // Server-authoritative: POST /undo / POST /redo append an inverse event
3214
+ // to the JSONL. The HMR push from that write flows back through useCanvas
3215
+ // → setLocalWidgets, so we don't optimistically mutate local state here.
3216
+ //
3217
+ // 404 handling: if the server can't find the target event, it was almost
3218
+ // certainly compacted away (compaction collapses history into a single
3219
+ // canvas_created baseline). We clear the per-tab stack so the toolbar
3220
+ // button greys out instead of silently no-op'ing on every click.
3093
3221
  const handleUndo = useCallback(() => {
3094
- const previous = undoRedo.undo(stateRef.current)
3095
- if (!previous) return
3096
- debouncedSave.cancel()
3222
+ const eventId = undoRedo.popUndo()
3223
+ if (!eventId) return
3224
+ flushAllPendingWidgetPatches()
3097
3225
  debouncedSourceSave.cancel()
3098
3226
  dirtyRef.current = true
3099
- setLocalWidgets(previous.widgets)
3100
- setLocalSources(previous.sources)
3101
- setLocalConnectors(previous.connectors ?? [])
3102
- queueWrite(() =>
3103
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
3104
- .catch((err) => console.error('[canvas] Failed to persist undo:', err))
3105
- )
3106
- }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
3227
+ queueWrite(async () => {
3228
+ try {
3229
+ const { status, data } = await undoEventApi(canvasId, eventId)
3230
+ if (status === 404) {
3231
+ console.warn('[canvas] Undo target was compacted away resetting undo stack')
3232
+ undoRedo.reset()
3233
+ return
3234
+ }
3235
+ if (data?.eventId) undoRedo.pushRedo(data.eventId)
3236
+ else if (data?.error) console.error('[canvas] Undo rejected:', data.error)
3237
+ } catch (err) {
3238
+ console.error('[canvas] Failed to persist undo:', err)
3239
+ }
3240
+ })
3241
+ }, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
3107
3242
 
3108
3243
  const handleRedo = useCallback(() => {
3109
- const next = undoRedo.redo(stateRef.current)
3110
- if (!next) return
3111
- debouncedSave.cancel()
3244
+ const eventId = undoRedo.popRedo()
3245
+ if (!eventId) return
3246
+ flushAllPendingWidgetPatches()
3112
3247
  debouncedSourceSave.cancel()
3113
3248
  dirtyRef.current = true
3114
- setLocalWidgets(next.widgets)
3115
- setLocalSources(next.sources)
3116
- setLocalConnectors(next.connectors ?? [])
3117
- queueWrite(() =>
3118
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
3119
- .catch((err) => console.error('[canvas] Failed to persist redo:', err))
3120
- )
3121
- }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
3249
+ queueWrite(async () => {
3250
+ try {
3251
+ const { status, data } = await redoEventApi(canvasId, eventId)
3252
+ if (status === 404) {
3253
+ console.warn('[canvas] Redo target was compacted away resetting redo stack')
3254
+ undoRedo.reset()
3255
+ return
3256
+ }
3257
+ if (data?.eventId) undoRedo.pushUndo(data.eventId)
3258
+ else if (data?.error) console.error('[canvas] Redo rejected:', data.error)
3259
+ } catch (err) {
3260
+ console.error('[canvas] Failed to persist redo:', err)
3261
+ }
3262
+ })
3263
+ }, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
3122
3264
 
3123
3265
  // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
3124
3266
  useEffect(() => {
@@ -3493,6 +3635,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3493
3635
  <div
3494
3636
  ref={scrollRef}
3495
3637
  data-storyboard-canvas-scroll
3638
+ data-sb-surface="canvas"
3496
3639
  data-sb-canvas-theme={canvasTheme}
3497
3640
  {...canvasPrimerAttrs}
3498
3641
  className={styles.canvasScroll}