@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
@@ -14,7 +14,7 @@ function setup() {
14
14
  const canvasDir = path.join(root, 'src', 'canvas')
15
15
  fs.mkdirSync(canvasDir, { recursive: true })
16
16
 
17
- const lastResponse = { status: null, body: null }
17
+ const lastResponse = { status: null, body: null, bytes: null, headers: null }
18
18
 
19
19
  const ctx = {
20
20
  root,
@@ -26,8 +26,24 @@ function setup() {
26
26
 
27
27
  const handler = createCanvasHandler(ctx)
28
28
 
29
+ // Streaming mock for binary GET responses (image fetches). Records the
30
+ // bytes + headers so tests can assert without coupling to real http.
31
+ function makeRes() {
32
+ let buf = Buffer.alloc(0)
33
+ return {
34
+ writeHead(status, headers) {
35
+ lastResponse.status = status
36
+ lastResponse.headers = headers
37
+ },
38
+ end(data) {
39
+ if (data) buf = Buffer.concat([buf, Buffer.isBuffer(data) ? data : Buffer.from(data)])
40
+ lastResponse.bytes = buf
41
+ },
42
+ }
43
+ }
44
+
29
45
  const invoke = (routePath, method, body = {}) =>
30
- handler(null, null, { path: routePath, method, body })
46
+ handler(null, makeRes(), { path: routePath, method, body })
31
47
 
32
48
  return { invoke, root, canvasDir, lastResponse }
33
49
  }
@@ -377,3 +393,350 @@ describe('POST /batch', () => {
377
393
  expect(refs['1']).toMatch(/^connector-/)
378
394
  })
379
395
  })
396
+
397
+ describe('POST /undo and POST /redo (event-id targeting)', () => {
398
+ let root, canvasDir, invoke, lastResponse
399
+
400
+ beforeEach(() => {
401
+ ({ invoke, root, canvasDir, lastResponse } = setup())
402
+ writeCanvas(canvasDir, 'undo-canvas', { title: 'Undo Canvas' })
403
+ })
404
+
405
+ afterEach(() => {
406
+ fs.rmSync(root, { recursive: true, force: true })
407
+ })
408
+
409
+ async function addSticky(text) {
410
+ await invoke('/widget', 'POST', {
411
+ name: 'undo-canvas',
412
+ type: 'sticky-note',
413
+ props: { text },
414
+ position: { x: 0, y: 0 },
415
+ })
416
+ return { widgetId: lastResponse.body.widget.id, eventId: lastResponse.body.eventId }
417
+ }
418
+
419
+ async function readState() {
420
+ await invoke('/read?name=undo-canvas', 'GET', {})
421
+ return lastResponse.body
422
+ }
423
+
424
+ it('appends an event id on every mutation', async () => {
425
+ const { eventId } = await addSticky('a')
426
+ expect(eventId).toMatch(/^evt_/)
427
+ })
428
+
429
+ it('reproduces the cq-enablement scenario: 3 deletes + undo restores only the most recent', async () => {
430
+ const a = await addSticky('a')
431
+ const b = await addSticky('b')
432
+ const c = await addSticky('c')
433
+
434
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: a.widgetId })
435
+ const delAEventId = lastResponse.body.eventId
436
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: b.widgetId })
437
+ const delBEventId = lastResponse.body.eventId
438
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: c.widgetId })
439
+ const delCEventId = lastResponse.body.eventId
440
+
441
+ expect((await readState()).widgets).toEqual([])
442
+
443
+ // Undo only the LAST delete (c).
444
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delCEventId })
445
+ expect(lastResponse.status).toBe(200)
446
+ expect(lastResponse.body.inverseEvent.event).toBe('widget_added')
447
+ expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'undo', of: delCEventId })
448
+
449
+ const after = await readState()
450
+ expect(after.widgets).toHaveLength(1)
451
+ expect(after.widgets[0].id).toBe(c.widgetId)
452
+ // a and b stay deleted — the bug class would have resurrected them too.
453
+ expect(after.widgets.map((w) => w.id)).not.toContain(a.widgetId)
454
+ expect(after.widgets.map((w) => w.id)).not.toContain(b.widgetId)
455
+
456
+ // Tracking that we don't leave delAEventId / delBEventId mentioned.
457
+ void delAEventId
458
+ void delBEventId
459
+ })
460
+
461
+ it('undo of widget_updated reverts the props using prevProps', async () => {
462
+ const { widgetId } = await addSticky('original')
463
+
464
+ await invoke('/widget', 'PATCH', {
465
+ name: 'undo-canvas',
466
+ widgetId,
467
+ props: { text: 'edited' },
468
+ })
469
+ const editEventId = lastResponse.body.eventIds[0]
470
+
471
+ let state = await readState()
472
+ expect(state.widgets[0].props.text).toBe('edited')
473
+
474
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: editEventId })
475
+ state = await readState()
476
+ expect(state.widgets[0].props.text).toBe('original')
477
+ })
478
+
479
+ it('undo of widget_moved reverts the position using prevPosition', async () => {
480
+ const { widgetId } = await addSticky('a')
481
+
482
+ await invoke('/widget', 'PATCH', {
483
+ name: 'undo-canvas',
484
+ widgetId,
485
+ position: { x: 500, y: 600 },
486
+ })
487
+ const moveEventId = lastResponse.body.eventIds[0]
488
+
489
+ let state = await readState()
490
+ expect(state.widgets[0].position).toEqual({ x: 500, y: 600 })
491
+
492
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: moveEventId })
493
+ state = await readState()
494
+ expect(state.widgets[0].position).toEqual({ x: 0, y: 0 })
495
+ })
496
+
497
+ it('redo of an undone delete re-removes the widget', async () => {
498
+ const { widgetId } = await addSticky('a')
499
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId })
500
+ const delEventId = lastResponse.body.eventId
501
+
502
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delEventId })
503
+ const undoEventId = lastResponse.body.eventId
504
+ expect((await readState()).widgets).toHaveLength(1)
505
+
506
+ // Redo with the undo event id — server emits the inverse of the undo,
507
+ // which is a fresh widget_removed.
508
+ await invoke('/redo', 'POST', { name: 'undo-canvas', eventId: undoEventId })
509
+ expect(lastResponse.body.inverseEvent.event).toBe('widget_removed')
510
+ expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'redo', of: undoEventId })
511
+
512
+ expect((await readState()).widgets).toEqual([])
513
+ })
514
+
515
+ it('rejects undo on an unknown event id with 404', async () => {
516
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: 'evt_does_not_exist' })
517
+ expect(lastResponse.status).toBe(404)
518
+ })
519
+
520
+ it('rejects undo on a non-undoable event (widgets_replaced) with 400', async () => {
521
+ // Trigger a widgets_replaced via PUT /update with replaceAll
522
+ await invoke('/update', 'PUT', {
523
+ name: 'undo-canvas',
524
+ widgets: [],
525
+ replaceAll: true,
526
+ })
527
+ // Read the file to get the event id of the widgets_replaced event.
528
+ const filePath = path.join(canvasDir, 'undo-canvas.canvas.jsonl')
529
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
530
+ const replacedEvt = lines.find((l) => l.event === 'widgets_replaced')
531
+ expect(replacedEvt?.id).toBeTruthy()
532
+
533
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: replacedEvt.id })
534
+ expect(lastResponse.status).toBe(400)
535
+ expect(lastResponse.body.error).toMatch(/cannot be undone/i)
536
+ })
537
+ })
538
+
539
+ describe('runaway-session hard ceiling compaction', () => {
540
+ let root, canvasDir, invoke, lastResponse
541
+
542
+ beforeEach(() => {
543
+ ({ invoke, root, canvasDir, lastResponse } = setup())
544
+ writeCanvas(canvasDir, 'big-canvas', { title: 'Big Canvas' })
545
+ })
546
+
547
+ afterEach(() => {
548
+ fs.rmSync(root, { recursive: true, force: true })
549
+ })
550
+
551
+ // setImmediate-based test helper
552
+ function flushSetImmediate() {
553
+ return new Promise((resolve) => setImmediate(resolve))
554
+ }
555
+
556
+ it('does not compact a small file (under the 2 MB threshold)', async () => {
557
+ const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
558
+ await invoke('/widget', 'POST', {
559
+ name: 'big-canvas',
560
+ type: 'sticky-note',
561
+ props: { text: 'hello' },
562
+ position: { x: 0, y: 0 },
563
+ })
564
+ await flushSetImmediate()
565
+ const sizeAfter = fs.statSync(filePath).size
566
+ expect(sizeAfter).toBeLessThan(2 * 1024 * 1024)
567
+ // File should still contain both canvas_created + widget_added events.
568
+ const events = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
569
+ expect(events.length).toBe(2)
570
+ expect(events[0].event).toBe('canvas_created')
571
+ expect(events[1].event).toBe('widget_added')
572
+ })
573
+
574
+ it('compacts the file once it exceeds the runaway threshold', async () => {
575
+ const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
576
+
577
+ // Pre-bloat the file past 2 MB by appending raw garbage events. Each
578
+ // event has a unique id so the materializer treats it as valid.
579
+ const padding = 'x'.repeat(4000)
580
+ const bloatLines = []
581
+ for (let i = 0; i < 600; i++) {
582
+ bloatLines.push(JSON.stringify({
583
+ id: `evt_bloat_${i}`,
584
+ event: 'widget_added',
585
+ timestamp: '2026-01-01',
586
+ widget: { id: `bloat-${i}`, type: 'sticky-note', position: { x: 0, y: 0 }, props: { text: padding } },
587
+ }))
588
+ }
589
+ fs.appendFileSync(filePath, bloatLines.join('\n') + '\n')
590
+ const sizeBefore = fs.statSync(filePath).size
591
+ expect(sizeBefore).toBeGreaterThan(2 * 1024 * 1024)
592
+
593
+ // Trigger appendEvent — this should detect the runaway size and kick
594
+ // off a compaction.
595
+ await invoke('/widget', 'POST', {
596
+ name: 'big-canvas',
597
+ type: 'sticky-note',
598
+ props: { text: 'trigger' },
599
+ position: { x: 0, y: 0 },
600
+ })
601
+ // Compaction runs on setImmediate; flush it.
602
+ await flushSetImmediate()
603
+ await flushSetImmediate()
604
+
605
+ const sizeAfter = fs.statSync(filePath).size
606
+ expect(sizeAfter).toBeLessThan(sizeBefore)
607
+
608
+ // After compaction, file should be a single canvas_created event
609
+ // containing all the materialized widgets.
610
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
611
+ expect(lines.length).toBe(1)
612
+ const baseline = JSON.parse(lines[0])
613
+ expect(baseline.event).toBe('canvas_created')
614
+ // 600 bloat widgets + 1 from the triggering append = 601.
615
+ expect(baseline.widgets.length).toBe(601)
616
+ // Sanity: state still reads cleanly via the normal /read endpoint.
617
+ await invoke('/read?name=big-canvas', 'GET', {})
618
+ expect(lastResponse.status).toBe(200)
619
+ expect(lastResponse.body.widgets.length).toBe(601)
620
+ })
621
+ })
622
+
623
+ describe('image routes — drafts/ subdirectory privacy layer', () => {
624
+ // Minimal 1x1 PNG (base64) for upload tests.
625
+ const PNG_DATA_URL =
626
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
627
+
628
+ it('routes a draft-canvas upload into images/drafts/ and returns the drafts/ prefix', async () => {
629
+ const { invoke, root, lastResponse } = setup()
630
+
631
+ await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'drafts/storyboarding' })
632
+
633
+ expect(lastResponse.status).toBe(201)
634
+ expect(lastResponse.body.success).toBe(true)
635
+ expect(lastResponse.body.filename).toMatch(/^drafts\//)
636
+ const filename = lastResponse.body.filename
637
+ const basename = filename.slice('drafts/'.length)
638
+ const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts', basename)
639
+ const publicFile = path.join(root, 'assets', 'canvas', 'images', basename)
640
+ expect(fs.existsSync(draftFile)).toBe(true)
641
+ expect(fs.existsSync(publicFile)).toBe(false)
642
+ })
643
+
644
+ it('routes a non-draft-canvas upload into images/ as before', async () => {
645
+ const { invoke, root, lastResponse } = setup()
646
+
647
+ await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'storyboarding' })
648
+
649
+ expect(lastResponse.status).toBe(201)
650
+ expect(lastResponse.body.filename).not.toMatch(/^drafts\//)
651
+ const filename = lastResponse.body.filename
652
+ const publicFile = path.join(root, 'assets', 'canvas', 'images', filename)
653
+ expect(fs.existsSync(publicFile)).toBe(true)
654
+ })
655
+
656
+ it('treats a canvas with `drafts` anywhere in its segments as draft', async () => {
657
+ const { invoke, root, lastResponse } = setup()
658
+
659
+ await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'widget/drafts/v6' })
660
+
661
+ expect(lastResponse.status).toBe(201)
662
+ expect(lastResponse.body.filename).toMatch(/^drafts\//)
663
+ const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts',
664
+ lastResponse.body.filename.slice('drafts/'.length))
665
+ expect(fs.existsSync(draftFile)).toBe(true)
666
+ })
667
+
668
+ it('GET /images/drafts/<basename> serves a draft image', async () => {
669
+ const { invoke, root, lastResponse } = setup()
670
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
671
+ fs.mkdirSync(draftsDir, { recursive: true })
672
+ const payload = Buffer.from([1, 2, 3])
673
+ fs.writeFileSync(path.join(draftsDir, 'hello.png'), payload)
674
+
675
+ await invoke('/images/drafts/hello.png', 'GET', {})
676
+ expect(lastResponse.status).toBe(200)
677
+ expect(lastResponse.bytes).toEqual(payload)
678
+ })
679
+
680
+ it('GET /images/<basename> does NOT fall back to drafts/', async () => {
681
+ const { invoke, root, lastResponse } = setup()
682
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
683
+ fs.mkdirSync(draftsDir, { recursive: true })
684
+ fs.writeFileSync(path.join(draftsDir, 'leak.png'), Buffer.from([1, 2, 3]))
685
+
686
+ await invoke('/images/leak.png', 'GET', {})
687
+ expect(lastResponse.status).toBe(404)
688
+ })
689
+
690
+ it('GET rejects deeper paths than one optional drafts/ segment', async () => {
691
+ const { invoke, lastResponse } = setup()
692
+
693
+ await invoke('/images/drafts/sub/foo.png', 'GET', {})
694
+ expect(lastResponse.status).toBe(400)
695
+ })
696
+
697
+ it('GET rejects path traversal even with drafts prefix', async () => {
698
+ const { invoke, lastResponse } = setup()
699
+
700
+ await invoke('/images/drafts/../etc.png', 'GET', {})
701
+ expect(lastResponse.status).toBe(400)
702
+ })
703
+
704
+ it('toggle-private preserves drafts/ prefix in returned filename', async () => {
705
+ const { invoke, root, lastResponse } = setup()
706
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
707
+ fs.mkdirSync(draftsDir, { recursive: true })
708
+ fs.writeFileSync(path.join(draftsDir, 'foo.png'), Buffer.from([1, 2, 3]))
709
+
710
+ await invoke('/image/toggle-private', 'POST', { filename: 'drafts/foo.png' })
711
+
712
+ expect(lastResponse.status).toBe(200)
713
+ expect(lastResponse.body).toEqual({ success: true, filename: 'drafts/~foo.png', private: true })
714
+ expect(fs.existsSync(path.join(draftsDir, '~foo.png'))).toBe(true)
715
+ expect(fs.existsSync(path.join(draftsDir, 'foo.png'))).toBe(false)
716
+ })
717
+
718
+ it('toggle-private still works on bare basenames in images/', async () => {
719
+ const { invoke, root, lastResponse } = setup()
720
+ const imagesDir = path.join(root, 'assets', 'canvas', 'images')
721
+ fs.mkdirSync(imagesDir, { recursive: true })
722
+ fs.writeFileSync(path.join(imagesDir, 'bar.png'), Buffer.from([1, 2, 3]))
723
+
724
+ await invoke('/image/toggle-private', 'POST', { filename: 'bar.png' })
725
+
726
+ expect(lastResponse.status).toBe(200)
727
+ expect(lastResponse.body.filename).toBe('~bar.png')
728
+ expect(fs.existsSync(path.join(imagesDir, '~bar.png'))).toBe(true)
729
+ })
730
+
731
+ it('duplicate preserves drafts/ prefix', async () => {
732
+ const { invoke, root, lastResponse } = setup()
733
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
734
+ fs.mkdirSync(draftsDir, { recursive: true })
735
+ fs.writeFileSync(path.join(draftsDir, 'storyboarding--2026-01-01--12-00-00.png'), Buffer.from([1, 2, 3]))
736
+
737
+ await invoke('/image/duplicate', 'POST', { filename: 'drafts/storyboarding--2026-01-01--12-00-00.png' })
738
+
739
+ expect(lastResponse.status).toBe(201)
740
+ expect(lastResponse.body.filename).toMatch(/^drafts\/storyboarding--/)
741
+ })
742
+ })
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { execSync } from 'node:child_process'
26
- import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync } from 'node:fs'
26
+ import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync, readdirSync } from 'node:fs'
27
27
  import { resolve, join, dirname } from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
29
29
  import { tmpdir } from 'node:os'
@@ -409,6 +409,110 @@ function publicSnapshotDir() {
409
409
  return join(process.cwd(), 'assets', '.storyboard-public', 'terminal-snapshots')
410
410
  }
411
411
 
412
+ /** Public snapshot index filenames (single consolidated files for all agents). */
413
+ const SNAPSHOT_INDEX_JSON = 'agents.snapshot.json'
414
+ const SNAPSHOT_INDEX_TXT = 'agents-txt.snapshot.json'
415
+
416
+ /** Max entries kept in the snapshot indexes before GC drops the oldest by timestamp. */
417
+ const SNAPSHOT_INDEX_MAX_ENTRIES = 50
418
+
419
+ /** In-memory cache of the snapshot indexes to avoid re-reading on every capture. */
420
+ let snapshotIndexCache = null
421
+ let snapshotTxtIndexCache = null
422
+ let snapshotLegacyFilesCleaned = false
423
+
424
+ function emptyIndex() {
425
+ return { version: 1, updatedAt: null, agents: {} }
426
+ }
427
+
428
+ function readIndexFile(filePath) {
429
+ try {
430
+ if (!existsSync(filePath)) return emptyIndex()
431
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
432
+ if (parsed && typeof parsed === 'object' && parsed.agents && typeof parsed.agents === 'object') {
433
+ return { version: parsed.version || 1, updatedAt: parsed.updatedAt || null, agents: parsed.agents }
434
+ }
435
+ } catch { /* fall through */ }
436
+ return emptyIndex()
437
+ }
438
+
439
+ function loadSnapshotIndexes() {
440
+ if (!snapshotIndexCache) {
441
+ snapshotIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON))
442
+ }
443
+ if (!snapshotTxtIndexCache) {
444
+ snapshotTxtIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_TXT))
445
+ }
446
+ return { jsonIndex: snapshotIndexCache, txtIndex: snapshotTxtIndexCache }
447
+ }
448
+
449
+ /**
450
+ * Garbage-collect index entries to keep size manageable.
451
+ * Drops oldest entries by timestamp until under the cap. Operates on both
452
+ * indexes together so they stay in sync.
453
+ */
454
+ function gcSnapshotIndexes(jsonIndex, txtIndex) {
455
+ const keys = Object.keys(jsonIndex.agents)
456
+ if (keys.length <= SNAPSHOT_INDEX_MAX_ENTRIES) return
457
+ const sorted = keys
458
+ .map((k) => ({ k, t: jsonIndex.agents[k]?.timestamp || '' }))
459
+ .sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
460
+ const dropCount = keys.length - SNAPSHOT_INDEX_MAX_ENTRIES
461
+ for (let i = 0; i < dropCount; i++) {
462
+ const id = sorted[i].k
463
+ delete jsonIndex.agents[id]
464
+ delete txtIndex.agents[id]
465
+ }
466
+ }
467
+
468
+ function atomicWriteJson(filePath, data) {
469
+ const tmpPath = filePath + '.tmp'
470
+ try {
471
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
472
+ renameSync(tmpPath, filePath)
473
+ } catch (err) {
474
+ try { if (existsSync(tmpPath)) unlinkSync(tmpPath) } catch {} // eslint-disable-line no-empty
475
+ throw err
476
+ }
477
+ }
478
+
479
+ function writeSnapshotIndexes(jsonIndex, txtIndex) {
480
+ const sDir = publicSnapshotDir()
481
+ try { mkdirSync(sDir, { recursive: true }) } catch {} // eslint-disable-line no-empty
482
+ const now = new Date().toISOString()
483
+ jsonIndex.updatedAt = now
484
+ txtIndex.updatedAt = now
485
+ try {
486
+ atomicWriteJson(join(sDir, SNAPSHOT_INDEX_JSON), jsonIndex)
487
+ } catch (err) {
488
+ devLog().logEvent('error', 'Failed to write agents.snapshot.json', { error: err.message })
489
+ }
490
+ try {
491
+ atomicWriteJson(join(sDir, SNAPSHOT_INDEX_TXT), txtIndex)
492
+ } catch (err) {
493
+ devLog().logEvent('error', 'Failed to write agents-txt.snapshot.json', { error: err.message })
494
+ }
495
+ }
496
+
497
+ /**
498
+ * One-time cleanup: remove legacy per-widget snapshot files left in the
499
+ * public dir from before the consolidated-index format. Runs on first call.
500
+ */
501
+ function cleanupLegacySnapshotFiles() {
502
+ if (snapshotLegacyFilesCleaned) return
503
+ snapshotLegacyFilesCleaned = true
504
+ const sDir = publicSnapshotDir()
505
+ if (!existsSync(sDir)) return
506
+ try {
507
+ for (const file of readdirSync(sDir)) {
508
+ if (file === SNAPSHOT_INDEX_JSON || file === SNAPSHOT_INDEX_TXT) continue
509
+ if (file.endsWith('.snapshot.json') || file.endsWith('.snapshot.txt')) {
510
+ try { unlinkSync(join(sDir, file)) } catch {} // eslint-disable-line no-empty
511
+ }
512
+ }
513
+ } catch { /* empty */ }
514
+ }
515
+
412
516
  /**
413
517
  * Read the `private` prop for a widget from the terminal config.
414
518
  * Returns true if the widget has props.private === true.
@@ -496,39 +600,28 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
496
600
  try { if (existsSync(txtTmpPath)) unlinkSync(txtTmpPath) } catch {} // eslint-disable-line no-empty
497
601
  }
498
602
 
499
- // ── Public snapshot (assets/.storyboard-public/terminal-snapshots/) ──
603
+ // ── Public snapshot index (assets/.storyboard-public/terminal-snapshots/) ──
604
+ // We maintain two consolidated index files keyed by widgetId:
605
+ // - agents.snapshot.json → structured data
606
+ // - agents-txt.snapshot.json → human-readable text payloads
607
+ cleanupLegacySnapshotFiles()
500
608
  const isPrivate = isWidgetPrivate(widgetId, canvasId)
501
- const sDir = publicSnapshotDir()
502
- const snapshotPath = join(sDir, `${widgetId}.snapshot.json`)
503
- const snapshotTxtPath = join(sDir, `${widgetId}.snapshot.txt`)
504
- const tildeSnapshotPath = join(sDir, `~${widgetId}.snapshot.json`)
505
- const tildeSnapshotTxtPath = join(sDir, `~${widgetId}.snapshot.txt`)
609
+ const { jsonIndex, txtIndex } = loadSnapshotIndexes()
506
610
 
507
611
  if (isPrivate) {
508
- // Rename existing public snapshots to tilde-prefixed (gitignored) versions
509
- if (existsSync(snapshotPath)) {
510
- try { renameSync(snapshotPath, tildeSnapshotPath) } catch {} // eslint-disable-line no-empty
511
- }
512
- if (existsSync(snapshotTxtPath)) {
513
- try { renameSync(snapshotTxtPath, tildeSnapshotTxtPath) } catch {} // eslint-disable-line no-empty
514
- }
612
+ let changed = false
613
+ if (jsonIndex.agents[widgetId]) { delete jsonIndex.agents[widgetId]; changed = true }
614
+ if (txtIndex.agents[widgetId]) { delete txtIndex.agents[widgetId]; changed = true }
615
+ if (changed) writeSnapshotIndexes(jsonIndex, txtIndex)
515
616
  return
516
617
  }
517
618
 
518
- // If un-privated, restore from tilde if the public files don't exist yet
519
- if (existsSync(tildeSnapshotPath) && !existsSync(snapshotPath)) {
520
- try { renameSync(tildeSnapshotPath, snapshotPath) } catch {} // eslint-disable-line no-empty
521
- }
522
- if (existsSync(tildeSnapshotTxtPath) && !existsSync(snapshotTxtPath)) {
523
- try { renameSync(tildeSnapshotTxtPath, snapshotTxtPath) } catch {} // eslint-disable-line no-empty
524
- }
525
-
526
619
  const snapshotScrollback = getRollingBufferContent(tmuxName, SNAPSHOT_MAX_AGE_MS)
527
620
  const strippedPane = stripAnsi(paneContent)
528
621
  const strippedScrollback = stripAnsi(snapshotScrollback)
529
622
 
530
- // ── JSON snapshot ──
531
- const snapshotData = {
623
+ // ── JSON entry ──
624
+ jsonIndex.agents[widgetId] = {
532
625
  widgetId,
533
626
  canvasId,
534
627
  prettyName: prettyName || null,
@@ -539,24 +632,7 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
539
632
  scrollback: strippedScrollback,
540
633
  }
541
634
 
542
- try {
543
- mkdirSync(sDir, { recursive: true })
544
- } catch (err) {
545
- devLog().logEvent('error', 'Failed to create public snapshot dir', { dir: sDir, error: err.message })
546
- return
547
- }
548
-
549
- const snapshotTmpPath = snapshotPath + '.tmp'
550
- try {
551
- writeFileSync(snapshotTmpPath, JSON.stringify(snapshotData, null, 2), 'utf8')
552
- renameSync(snapshotTmpPath, snapshotPath)
553
- } catch (err) {
554
- devLog().logEvent('error', 'Failed to write public snapshot JSON', { widgetId, error: err.message, path: snapshotPath })
555
- try { if (existsSync(snapshotTmpPath)) unlinkSync(snapshotTmpPath) } catch {} // eslint-disable-line no-empty
556
- }
557
-
558
- // ── Human-readable text snapshot ──
559
- const snapshotTxtTmpPath = snapshotTxtPath + '.tmp'
635
+ // ── Text entry ──
560
636
  try {
561
637
  const screenText = strippedPane.replace(/\r\n/g, '\n').replace(/\n+$/, '')
562
638
  const scrollText = strippedScrollback.replace(/\r\n/g, '\n').replace(/\n+$/, '')
@@ -583,12 +659,13 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
583
659
  snpTxt += scrollText + '\n'
584
660
  }
585
661
 
586
- writeFileSync(snapshotTxtTmpPath, snpTxt, 'utf8')
587
- renameSync(snapshotTxtTmpPath, snapshotTxtPath)
662
+ txtIndex.agents[widgetId] = snpTxt
588
663
  } catch (err) {
589
- devLog().logEvent('error', 'Failed to write public snapshot txt', { widgetId, error: err.message })
590
- try { if (existsSync(snapshotTxtTmpPath)) unlinkSync(snapshotTxtTmpPath) } catch {} // eslint-disable-line no-empty
664
+ devLog().logEvent('error', 'Failed to build snapshot text entry', { widgetId, error: err.message })
591
665
  }
666
+
667
+ gcSnapshotIndexes(jsonIndex, txtIndex)
668
+ writeSnapshotIndexes(jsonIndex, txtIndex)
592
669
  }
593
670
 
594
671
  /** Start periodic snapshot capture for a session */
@@ -1209,11 +1286,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1209
1286
  const finalize = () => {
1210
1287
  if (completed) return
1211
1288
  completed = true
1289
+ // Identity/role MUST be injected before postStartup. Commands like
1290
+ // `/autopilot` flip the agent into a mode where the next message is
1291
+ // consumed as a task — so we want canvas context in conversation
1292
+ // history *first*, then the mode-toggling slash command.
1293
+ injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1294
+ injectRoleMessageForWidget(tmuxName, widgetId)
1212
1295
  if (postStartup) {
1213
1296
  tmuxSubmit(tmuxName, postStartup)
1214
1297
  }
1215
- injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1216
- injectRoleMessageForWidget(tmuxName, widgetId)
1217
1298
  // Bind to messaging bus for live delivery + backfill missed messages
1218
1299
  setTimeout(() => {
1219
1300
  migratePendingMessages(widgetId, branch, canvasId).then(() => {
@@ -1408,11 +1489,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1408
1489
  clearInterval(pollInterval)
1409
1490
  try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
1410
1491
  setTimeout(() => {
1492
+ // Identity/role/messaging-bind MUST happen BEFORE postStartup.
1493
+ // Slash commands like `/autopilot` flip the agent into a mode
1494
+ // where the next message is consumed as a task — so we want
1495
+ // canvas context (widgetId, configFile, role) in conversation
1496
+ // history first, then the mode-toggling slash command last.
1497
+ softBind()
1411
1498
  if (postStartup) {
1412
- tmuxSubmit(tmuxName, postStartup)
1499
+ setTimeout(() => tmuxSubmit(tmuxName, postStartup), 1500)
1413
1500
  }
1414
- // Ensure identity/bind happened (no-op if already soft-bound)
1415
- softBind()
1416
1501
  }, 500)
1417
1502
  if (reason === 'soft-bind-timeout') {
1418
1503
  devLog(`[terminal-server] readiness slow for ${widgetId} (${tmuxName}); soft-binding now, will run postStartup when .ready arrives`)
@@ -1765,15 +1850,17 @@ export function readTerminalBuffer(widgetId, { maxLength } = {}) {
1765
1850
  }
1766
1851
 
1767
1852
  /**
1768
- * Read a terminal public snapshot by widget ID.
1769
- * Checks new path first, falls back to legacy path.
1853
+ * Read a terminal public snapshot by widget ID from the consolidated index.
1854
+ * Falls back to legacy nested path if not present.
1770
1855
  */
1771
1856
  export function readTerminalSnapshot(widgetId, canvasId) {
1772
- // New path: assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json
1773
- const newPath = join(publicSnapshotDir(), `${widgetId}.snapshot.json`)
1857
+ // New: consolidated index at agents.snapshot.json
1858
+ const indexPath = join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON)
1774
1859
  try {
1775
- if (existsSync(newPath)) {
1776
- return JSON.parse(readFileSync(newPath, 'utf8'))
1860
+ if (existsSync(indexPath)) {
1861
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'))
1862
+ const entry = parsed?.agents?.[widgetId]
1863
+ if (entry) return entry
1777
1864
  }
1778
1865
  } catch { /* empty */ }
1779
1866