@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
@@ -1,86 +1,101 @@
1
1
  import { useRef, useState, useCallback } from 'react'
2
2
 
3
3
  const MAX_HISTORY = 100
4
- const COALESCE_MS = 2000
5
4
 
6
5
  /**
7
- * Snapshot-based undo/redo history for canvas widgets.
6
+ * Per-tab undo/redo history for canvas events.
8
7
  *
9
- * Tracks past/future stacks of widget array clones. The present state
10
- * is always the live `localWidgets` this hook only manages history.
8
+ * Maintains two stacks of event IDs:
9
+ * undoStack ids of user-initiated forward events, newest last.
10
+ * redoStack — ids of undo events (the inverse events appended by the
11
+ * server's POST /undo), newest last.
11
12
  *
12
- * Edit coalescing: continuous edits to the same widget within COALESCE_MS
13
- * are merged into one undo step (like Figma).
13
+ * Workflow:
14
+ * Every user-initiated mutation that succeeds calls `track(eventId)`
15
+ * pushes onto undoStack and clears redoStack.
16
+ * • `popUndo()` returns the next id to undo. Caller POSTs /undo and on
17
+ * success calls `pushRedo(inverseId)`.
18
+ * • `popRedo()` returns the next id to redo. Caller POSTs /redo and on
19
+ * success calls `pushUndo(inverseId)` (so the redo is itself undoable).
20
+ *
21
+ * This hook holds *no* widget data — undo state lives in the JSONL. The
22
+ * stacks are per-tab and ephemeral; refreshing the page resets them.
14
23
  */
15
24
  export default function useUndoRedo() {
16
- const pastRef = useRef([])
17
- const futureRef = useRef([])
18
- const lastActionRef = useRef({ type: null, widgetId: null, time: 0 })
19
- // State counter drives canUndo/canRedo re-renders without cloning the stacks
20
- const [counts, setCounts] = useState({ past: 0, future: 0 })
25
+ const undoStackRef = useRef([])
26
+ const redoStackRef = useRef([])
27
+ const [counts, setCounts] = useState({ undo: 0, redo: 0 })
28
+
29
+ const syncCounts = useCallback(() => {
30
+ setCounts({ undo: undoStackRef.current.length, redo: redoStackRef.current.length })
31
+ }, [])
21
32
 
22
- const snapshot = useCallback((currentWidgets, actionType, widgetId) => {
23
- const widgets = currentWidgets ?? []
33
+ const track = useCallback((eventId) => {
34
+ if (!eventId || typeof eventId !== 'string') return
35
+ undoStackRef.current.push(eventId)
36
+ if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
37
+ // New mutation invalidates the redo chain
38
+ redoStackRef.current = []
39
+ syncCounts()
40
+ }, [syncCounts])
24
41
 
25
- // Edit coalescing skip snapshot if same edit target within timeout
26
- if (actionType === 'edit' && widgetId) {
27
- const last = lastActionRef.current
28
- const now = Date.now()
29
- if (
30
- last.type === 'edit' &&
31
- last.widgetId === widgetId &&
32
- now - last.time < COALESCE_MS
33
- ) {
34
- lastActionRef.current = { type: 'edit', widgetId, time: now }
35
- return
42
+ const trackMany = useCallback((eventIds) => {
43
+ if (!Array.isArray(eventIds)) return
44
+ for (const id of eventIds) {
45
+ if (id && typeof id === 'string') {
46
+ undoStackRef.current.push(id)
47
+ if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
36
48
  }
37
49
  }
50
+ redoStackRef.current = []
51
+ syncCounts()
52
+ }, [syncCounts])
38
53
 
39
- pastRef.current.push(structuredClone(widgets))
40
- if (pastRef.current.length > MAX_HISTORY) pastRef.current.shift()
41
- futureRef.current = []
42
- lastActionRef.current = {
43
- type: actionType,
44
- widgetId: widgetId || null,
45
- time: Date.now(),
46
- }
47
- setCounts({ past: pastRef.current.length, future: 0 })
48
- }, [])
54
+ const popUndo = useCallback(() => {
55
+ const id = undoStackRef.current.pop()
56
+ syncCounts()
57
+ return id ?? null
58
+ }, [syncCounts])
49
59
 
50
- const undo = useCallback((currentWidgets) => {
51
- if (pastRef.current.length === 0) return null
52
- futureRef.current.push(structuredClone(currentWidgets))
53
- const previous = pastRef.current.pop()
54
- lastActionRef.current = { type: 'undo', widgetId: null, time: Date.now() }
55
- setCounts({ past: pastRef.current.length, future: futureRef.current.length })
56
- return previous
57
- }, [])
60
+ const popRedo = useCallback(() => {
61
+ const id = redoStackRef.current.pop()
62
+ syncCounts()
63
+ return id ?? null
64
+ }, [syncCounts])
58
65
 
59
- const redo = useCallback((currentWidgets) => {
60
- if (futureRef.current.length === 0) return null
61
- pastRef.current.push(structuredClone(currentWidgets))
62
- const next = futureRef.current.pop()
63
- lastActionRef.current = { type: 'redo', widgetId: null, time: Date.now() }
64
- setCounts({ past: pastRef.current.length, future: futureRef.current.length })
65
- return next
66
- }, [])
66
+ const pushRedo = useCallback((eventId) => {
67
+ if (!eventId || typeof eventId !== 'string') return
68
+ redoStackRef.current.push(eventId)
69
+ if (redoStackRef.current.length > MAX_HISTORY) redoStackRef.current.shift()
70
+ syncCounts()
71
+ }, [syncCounts])
72
+
73
+ const pushUndo = useCallback((eventId) => {
74
+ if (!eventId || typeof eventId !== 'string') return
75
+ undoStackRef.current.push(eventId)
76
+ if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
77
+ syncCounts()
78
+ }, [syncCounts])
67
79
 
68
80
  const reset = useCallback(() => {
69
- pastRef.current = []
70
- futureRef.current = []
71
- lastActionRef.current = { type: null, widgetId: null, time: 0 }
81
+ undoStackRef.current = []
82
+ redoStackRef.current = []
72
83
  setCounts((prev) => {
73
- if (prev.past === 0 && prev.future === 0) return prev
74
- return { past: 0, future: 0 }
84
+ if (prev.undo === 0 && prev.redo === 0) return prev
85
+ return { undo: 0, redo: 0 }
75
86
  })
76
87
  }, [])
77
88
 
78
89
  return {
79
- snapshot,
80
- undo,
81
- redo,
90
+ track,
91
+ trackMany,
92
+ popUndo,
93
+ popRedo,
94
+ pushRedo,
95
+ pushUndo,
82
96
  reset,
83
- canUndo: counts.past > 0,
84
- canRedo: counts.future > 0,
97
+ canUndo: counts.undo > 0,
98
+ canRedo: counts.redo > 0,
85
99
  }
86
100
  }
101
+
@@ -1,231 +1,99 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
+ import { describe, it, expect } from 'vitest'
2
3
  import useUndoRedo from './useUndoRedo.js'
3
4
 
4
- describe('useUndoRedo', () => {
5
+ describe('useUndoRedo (canvas)', () => {
5
6
  it('starts with canUndo and canRedo as false', () => {
6
7
  const { result } = renderHook(() => useUndoRedo())
7
8
  expect(result.current.canUndo).toBe(false)
8
9
  expect(result.current.canRedo).toBe(false)
9
10
  })
10
11
 
11
- it('can undo after a snapshot', () => {
12
+ it('track pushes an event id and enables undo', () => {
12
13
  const { result } = renderHook(() => useUndoRedo())
13
- const widgets = [{ id: '1', type: 'sticky-note', props: { text: 'a' }, position: { x: 0, y: 0 } }]
14
-
15
- act(() => result.current.snapshot(widgets, 'add'))
16
-
14
+ act(() => result.current.track('evt_a'))
17
15
  expect(result.current.canUndo).toBe(true)
18
16
  expect(result.current.canRedo).toBe(false)
19
17
  })
20
18
 
21
- it('undo returns the previous state and enables redo', () => {
19
+ it('popUndo returns the most recent tracked id; pushRedo enables redo', () => {
22
20
  const { result } = renderHook(() => useUndoRedo())
23
- const before = [{ id: '1', props: { text: 'a' } }]
24
- const after = [{ id: '1', props: { text: 'b' } }]
21
+ act(() => result.current.track('evt_a'))
22
+ act(() => result.current.track('evt_b'))
25
23
 
26
- act(() => result.current.snapshot(before, 'edit', '1'))
24
+ let popped
25
+ act(() => { popped = result.current.popUndo() })
27
26
 
28
- let restored
29
- act(() => { restored = result.current.undo(after) })
27
+ expect(popped).toBe('evt_b')
28
+ expect(result.current.canUndo).toBe(true)
29
+ expect(result.current.canRedo).toBe(false)
30
30
 
31
- expect(restored).toEqual(before)
32
- expect(result.current.canUndo).toBe(false)
31
+ act(() => result.current.pushRedo('evt_b_inv'))
33
32
  expect(result.current.canRedo).toBe(true)
34
33
  })
35
34
 
36
- it('redo returns the next state', () => {
35
+ it('popRedo returns the most recent redo id; pushUndo restores undo availability', () => {
37
36
  const { result } = renderHook(() => useUndoRedo())
38
- const before = [{ id: '1', props: { text: 'a' } }]
39
- const after = [{ id: '1', props: { text: 'b' } }]
37
+ act(() => result.current.track('evt_a'))
38
+ act(() => { result.current.popUndo() })
39
+ act(() => result.current.pushRedo('evt_a_inv'))
40
40
 
41
- act(() => result.current.snapshot(before, 'edit', '1'))
42
- act(() => { result.current.undo(after) })
41
+ expect(result.current.canRedo).toBe(true)
43
42
 
44
- let redone
45
- act(() => { redone = result.current.redo(before) })
43
+ let popped
44
+ act(() => { popped = result.current.popRedo() })
45
+ expect(popped).toBe('evt_a_inv')
46
+ expect(result.current.canRedo).toBe(false)
46
47
 
47
- expect(redone).toEqual(after)
48
+ act(() => result.current.pushUndo('evt_a_inv_inv'))
48
49
  expect(result.current.canUndo).toBe(true)
49
- expect(result.current.canRedo).toBe(false)
50
50
  })
51
51
 
52
- it('new mutation after undo clears the redo chain', () => {
52
+ it('new track call after undo clears the redo stack', () => {
53
53
  const { result } = renderHook(() => useUndoRedo())
54
- const s0 = [{ id: '1' }]
55
- const s1 = [{ id: '1' }, { id: '2' }]
56
- const s2 = [{ id: '1' }, { id: '3' }] // eslint-disable-line no-unused-vars
57
-
58
- act(() => result.current.snapshot(s0, 'add'))
59
- act(() => result.current.undo(s1))
54
+ act(() => result.current.track('evt_a'))
55
+ act(() => { result.current.popUndo() })
56
+ act(() => result.current.pushRedo('evt_a_inv'))
60
57
  expect(result.current.canRedo).toBe(true)
61
58
 
62
- // New mutation breaks redo
63
- act(() => result.current.snapshot(s0, 'add'))
59
+ act(() => result.current.track('evt_b'))
64
60
  expect(result.current.canRedo).toBe(false)
65
61
  expect(result.current.canUndo).toBe(true)
66
62
  })
67
63
 
68
- it('supports multi-step undo-redo-undo chains', () => {
64
+ it('trackMany pushes multiple ids and clears redo', () => {
69
65
  const { result } = renderHook(() => useUndoRedo())
70
- const s0 = [{ id: '1' }]
71
- const s1 = [{ id: '1' }, { id: '2' }]
72
- const s2 = [{ id: '1' }, { id: '2' }, { id: '3' }]
73
- const s3 = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]
74
-
75
- // Build history: s0 → s1 → s2 → s3
76
- act(() => result.current.snapshot(s0, 'add'))
77
- act(() => result.current.snapshot(s1, 'add'))
78
- act(() => result.current.snapshot(s2, 'add'))
79
-
80
- // present = s3, past = [s0, s1, s2]
81
- // Undo to s2
82
- let r
83
- act(() => { r = result.current.undo(s3) })
84
- expect(r).toEqual(s2)
85
-
86
- // Undo to s1
87
- act(() => { r = result.current.undo(s2) })
88
- expect(r).toEqual(s1)
89
-
90
- // Redo to s2
91
- act(() => { r = result.current.redo(s1) })
92
- expect(r).toEqual(s2)
93
-
94
- // Redo to s3
95
- act(() => { r = result.current.redo(s2) })
96
- expect(r).toEqual(s3)
97
-
98
- // Undo to s2 again
99
- act(() => { r = result.current.undo(s3) })
100
- expect(r).toEqual(s2)
101
-
102
- // Undo to s1
103
- act(() => { r = result.current.undo(s2) })
104
- expect(r).toEqual(s1)
105
-
106
- // Undo to s0
107
- act(() => { r = result.current.undo(s1) })
108
- expect(r).toEqual(s0)
109
-
110
- // Can't undo further
111
- expect(result.current.canUndo).toBe(false)
112
- act(() => { r = result.current.undo(s0) })
113
- expect(r).toBeNull()
114
- })
115
-
116
- it('coalesces edits to the same widget within timeout', () => {
117
- const { result } = renderHook(() => useUndoRedo())
118
- const s0 = [{ id: '1', props: { text: '' } }]
119
-
120
- // First edit — creates snapshot
121
- act(() => result.current.snapshot(s0, 'edit', '1'))
66
+ act(() => result.current.trackMany(['evt_a', 'evt_b', 'evt_c']))
122
67
  expect(result.current.canUndo).toBe(true)
123
-
124
- // Second edit to same widget within 2s — coalesced, no new snapshot
125
- act(() => result.current.snapshot(s0, 'edit', '1'))
126
- // Still only one entry in past
127
- let r
128
- act(() => { r = result.current.undo([{ id: '1', props: { text: 'abc' } }]) })
129
- expect(r).toEqual(s0)
130
- // No more undo after that
131
- expect(result.current.canUndo).toBe(false)
68
+ let popped
69
+ act(() => { popped = result.current.popUndo() })
70
+ expect(popped).toBe('evt_c')
132
71
  })
133
72
 
134
- it('does NOT coalesce edits to different widgets', () => {
73
+ it('ignores empty / non-string ids', () => {
135
74
  const { result } = renderHook(() => useUndoRedo())
136
- const s0 = [{ id: '1' }, { id: '2' }]
137
- const s1 = [{ id: '1', props: { text: 'a' } }, { id: '2' }]
138
-
139
- act(() => result.current.snapshot(s0, 'edit', '1'))
140
- act(() => result.current.snapshot(s1, 'edit', '2'))
141
-
142
- // Two entries in past
143
- expect(result.current.canUndo).toBe(true)
144
- act(() => { result.current.undo([]) })
145
- expect(result.current.canUndo).toBe(true)
146
- act(() => { result.current.undo([]) })
75
+ act(() => result.current.track(null))
76
+ act(() => result.current.track(undefined))
77
+ act(() => result.current.track(''))
78
+ act(() => result.current.track(42))
147
79
  expect(result.current.canUndo).toBe(false)
148
80
  })
149
81
 
150
- it('does NOT coalesce edit after a different action type', () => {
82
+ it('reset clears both stacks', () => {
151
83
  const { result } = renderHook(() => useUndoRedo())
152
- const s0 = [{ id: '1' }]
153
- const s1 = [{ id: '1' }, { id: '2' }]
154
-
155
- act(() => result.current.snapshot(s0, 'edit', '1'))
156
- act(() => result.current.snapshot(s1, 'add'))
157
- act(() => result.current.snapshot(s1, 'edit', '1'))
158
-
159
- // Three entries in past (edit + add + edit — not coalesced because add broke the sequence)
160
- act(() => { result.current.undo([]) })
161
- act(() => { result.current.undo([]) })
162
- act(() => { result.current.undo([]) })
163
- expect(result.current.canUndo).toBe(false)
164
- })
165
-
166
- it('reset clears all history', () => {
167
- const { result } = renderHook(() => useUndoRedo())
168
- act(() => result.current.snapshot([{ id: '1' }], 'add'))
169
- act(() => result.current.snapshot([{ id: '1' }, { id: '2' }], 'add'))
170
- expect(result.current.canUndo).toBe(true)
84
+ act(() => result.current.track('evt_a'))
85
+ act(() => { result.current.popUndo() })
86
+ act(() => result.current.pushRedo('evt_a_inv'))
171
87
 
172
88
  act(() => result.current.reset())
173
89
  expect(result.current.canUndo).toBe(false)
174
90
  expect(result.current.canRedo).toBe(false)
175
91
  })
176
92
 
177
- it('undo returns null when history is empty', () => {
178
- const { result } = renderHook(() => useUndoRedo())
179
- let r
180
- act(() => { r = result.current.undo([]) })
181
- expect(r).toBeNull()
182
- })
183
-
184
- it('redo returns null when future is empty', () => {
185
- const { result } = renderHook(() => useUndoRedo())
186
- let r
187
- act(() => { r = result.current.redo([]) })
188
- expect(r).toBeNull()
189
- })
190
-
191
- it('snapshots are deep clones (mutations do not leak)', () => {
192
- const { result } = renderHook(() => useUndoRedo())
193
- const widgets = [{ id: '1', props: { text: 'original' } }]
194
-
195
- act(() => result.current.snapshot(widgets, 'add'))
196
-
197
- // Mutate the original array
198
- widgets[0].props.text = 'mutated'
199
-
200
- let restored
201
- act(() => { restored = result.current.undo(widgets) })
202
- expect(restored[0].props.text).toBe('original')
203
- })
204
-
205
- it('caps history at 100 entries', () => {
93
+ it('popUndo on empty stack returns null', () => {
206
94
  const { result } = renderHook(() => useUndoRedo())
207
-
208
- for (let i = 0; i < 110; i++) {
209
- act(() => result.current.snapshot([{ id: String(i) }], 'add'))
210
- }
211
-
212
- // Should be capped at 100 — undo 100 times, then can't undo further
213
- let count = 0
214
- let r = true
215
- while (r !== null) {
216
- act(() => { r = result.current.undo([]) })
217
- if (r !== null) count++
218
- }
219
- expect(count).toBe(100)
220
- })
221
-
222
- it('snapshots null widgets as empty array (first widget on new canvas)', () => {
223
- const { result } = renderHook(() => useUndoRedo())
224
- act(() => result.current.snapshot(null, 'add'))
225
- expect(result.current.canUndo).toBe(true)
226
-
227
- let restored
228
- act(() => { restored = result.current.undo([{ id: '1' }]) })
229
- expect(restored).toEqual([])
95
+ let popped
96
+ act(() => { popped = result.current.popUndo() })
97
+ expect(popped).toBeNull()
230
98
  })
231
99
  })
@@ -13,7 +13,7 @@
13
13
  * Combined with `getState` for toggle resolution and `onAction` for dispatch.
14
14
  */
15
15
  import { ScreenNormalIcon } from '@primer/octicons-react'
16
- import { Tooltip } from '@primer/react'
16
+ import Tooltip from '../../Tooltip.jsx'
17
17
  import { ICON_REGISTRY } from './widgetIcons.jsx'
18
18
  import { getWidgetMeta } from './widgetConfig.js'
19
19
  import Icon from '../../Icon.jsx'
@@ -48,32 +48,41 @@ export default function FrozenTerminalOverlay({ widgetId, onActivate }) {
48
48
 
49
49
  async function fetchSnapshot() {
50
50
  const baseUrl = getBaseUrl()
51
- const urls = [
52
- `${baseUrl}_storyboard/canvas/terminal-snapshot/${widgetId}`,
53
- `${baseUrl}_storyboard/terminal-snapshots/${widgetId}.snapshot.json`,
54
- ]
55
-
56
- for (const url of urls) {
57
- try {
58
- const res = await fetch(url)
59
- if (!res.ok) continue
51
+ // Try the dev-server REST endpoint first, then fall back to the static
52
+ // consolidated index (production builds / no server).
53
+ const restUrl = `${baseUrl}_storyboard/canvas/terminal-snapshot/${widgetId}`
54
+ const indexUrl = `${baseUrl}_storyboard/terminal-snapshots/agents.snapshot.json`
55
+
56
+ try {
57
+ const res = await fetch(restUrl)
58
+ if (res.ok) {
60
59
  const data = await res.json()
61
60
  if (cancelled) return
62
61
  const text = data.paneContent || data.content || data.output || ''
63
- if (!text) continue
64
-
65
- const converter = await getConverter()
66
- if (cancelled) return
67
- if (converter) {
68
- setHtml(converter.toHtml(text))
69
- } else {
70
- setPlainText(stripAnsi(text))
62
+ if (text) {
63
+ const converter = await getConverter()
64
+ if (cancelled) return
65
+ if (converter) setHtml(converter.toHtml(text))
66
+ else setPlainText(stripAnsi(text))
67
+ return
71
68
  }
72
- return
73
- } catch {
74
- continue
75
69
  }
76
- }
70
+ } catch { /* fall through to static index */ }
71
+
72
+ try {
73
+ const res = await fetch(indexUrl)
74
+ if (!res.ok) return
75
+ const data = await res.json()
76
+ if (cancelled) return
77
+ const entry = data?.agents?.[widgetId]
78
+ if (!entry) return
79
+ const text = entry.paneContent || entry.content || entry.output || ''
80
+ if (!text) return
81
+ const converter = await getConverter()
82
+ if (cancelled) return
83
+ if (converter) setHtml(converter.toHtml(text))
84
+ else setPlainText(stripAnsi(text))
85
+ } catch { /* empty */ }
77
86
  }
78
87
 
79
88
  fetchSnapshot()
@@ -137,7 +137,10 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
137
137
  const blobUrl = URL.createObjectURL(blob)
138
138
  const a = document.createElement('a')
139
139
  a.href = blobUrl
140
- a.download = src.replace(/^~/, '')
140
+ // Strip optional drafts/ directory prefix and leading ~ from the
141
+ // downloaded filename — the user wants the bare basename.
142
+ const baseName = src.includes('/') ? src.slice(src.lastIndexOf('/') + 1) : src
143
+ a.download = baseName.replace(/^~/, '')
141
144
  document.body.appendChild(a)
142
145
  a.click()
143
146
  document.body.removeChild(a)
@@ -156,7 +159,9 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
156
159
  .catch((err) => console.error('[canvas] Failed to copy image:', err))
157
160
  } else if (actionId === 'copy-file-path') {
158
161
  if (!src) return
159
- navigator.clipboard.writeText(`src/canvas/images/${src}`).catch(() => {})
162
+ // `src` may include an optional `drafts/` prefix; in both cases the
163
+ // file lives under assets/canvas/images/.
164
+ navigator.clipboard.writeText(`assets/canvas/images/${src}`).catch(() => {})
160
165
  }
161
166
  }
162
167
  }), [src, onUpdate])
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImper
2
2
  import { remark } from 'remark'
3
3
  import remarkGfm from 'remark-gfm'
4
4
  import remarkHtml from 'remark-html'
5
- import { MarkGithubIcon } from '@primer/octicons-react'
5
+ import { GitBranchIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, MarkGithubIcon } from '@primer/octicons-react'
6
6
  import WidgetWrapper from './WidgetWrapper.jsx'
7
7
  import ResizeHandle from './ResizeHandle.jsx'
8
8
  import { readProp, linkPreviewSchema } from './widgetProps.js'
@@ -119,6 +119,48 @@ function getCommentKindLabel(github) {
119
119
  return 'Comment'
120
120
  }
121
121
 
122
+ const PR_STATE_META = {
123
+ merged: { label: 'Merged', Icon: GitMergeIcon, className: 'prStateMerged' },
124
+ closed: { label: 'Closed', Icon: GitPullRequestClosedIcon, className: 'prStateClosed' },
125
+ draft: { label: 'Draft', Icon: GitPullRequestDraftIcon, className: 'prStateDraft' },
126
+ open: { label: 'Open', Icon: GitPullRequestIcon, className: 'prStateOpen' },
127
+ }
128
+
129
+ function PullRequestMeta({ github }) {
130
+ if (github?.kind !== 'pull_request') return null
131
+ const stateKey = github?.state || 'open'
132
+ const stateMeta = PR_STATE_META[stateKey] || PR_STATE_META.open
133
+ const { Icon: StateIcon } = stateMeta
134
+ const baseRef = github?.baseRef || null
135
+ const headRef = github?.headRef || null
136
+ const additions = typeof github?.additions === 'number' ? github.additions : null
137
+ const deletions = typeof github?.deletions === 'number' ? github.deletions : null
138
+ const hasDiff = additions !== null || deletions !== null
139
+
140
+ return (
141
+ <div className={styles.prMeta}>
142
+ <span className={`${styles.prStateBadge} ${styles[stateMeta.className]}`}>
143
+ <StateIcon size={12} />
144
+ {stateMeta.label}
145
+ </span>
146
+ {(baseRef || headRef) && (
147
+ <span className={styles.prBranchRef} title={`${headRef || '?'} → ${baseRef || '?'}`}>
148
+ <GitBranchIcon size={12} />
149
+ <span className={styles.prBranchName}>{baseRef || '?'}</span>
150
+ <span className={styles.prBranchArrow}>←</span>
151
+ <span className={styles.prBranchName}>{headRef || '?'}</span>
152
+ </span>
153
+ )}
154
+ {hasDiff && (
155
+ <span className={styles.prDiffStat}>
156
+ {additions !== null && <span className={styles.prDiffAdd}>+{additions}</span>}
157
+ {deletions !== null && <span className={styles.prDiffDel}>-{deletions}</span>}
158
+ </span>
159
+ )}
160
+ </div>
161
+ )
162
+ }
163
+
122
164
  function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, expandMode, onCloseExpand }) {
123
165
  const authors = Array.isArray(github?.authors)
124
166
  ? github.authors.filter((a) => typeof a === 'string' && a.trim())
@@ -209,6 +251,7 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, e
209
251
  {primaryAuthor && createdAgo ? ` opened ${createdAgo}` : createdAgo ? `Opened ${createdAgo}` : ''}
210
252
  </span>
211
253
  </div>
254
+ <PullRequestMeta github={github} />
212
255
  </div>
213
256
 
214
257
  {bodyHtml && (
@@ -243,6 +286,7 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, e
243
286
  )}
244
287
  {createdAgo && <span className={styles.expandedBylineText}>{primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`}</span>}
245
288
  </div>
289
+ <PullRequestMeta github={github} />
246
290
  </header>
247
291
  {bodyHtml && <div className={styles.expandedIssueBody} dangerouslySetInnerHTML={{ __html: bodyHtml }} />}
248
292
  </div>