@agent-native/core 0.69.0 → 0.70.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 (336) hide show
  1. package/README.md +15 -15
  2. package/corpus/README.md +2 -2
  3. package/corpus/core/CHANGELOG.md +109 -0
  4. package/corpus/core/docs/design/durable-agent-runs.md +217 -0
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +89 -0
  7. package/corpus/core/src/agent/model-config.ts +0 -2
  8. package/corpus/core/src/agent/run-loop-with-resume.ts +48 -0
  9. package/corpus/core/src/changelog/parse.ts +245 -0
  10. package/corpus/core/src/cli/changelog.ts +199 -0
  11. package/corpus/core/src/cli/index.ts +19 -0
  12. package/corpus/core/src/cli/recap.ts +24 -1
  13. package/corpus/core/src/cli/skills.ts +74 -47
  14. package/corpus/core/src/client/AgentPanel.tsx +5 -3
  15. package/corpus/core/src/client/AssistantChat.tsx +8 -0
  16. package/corpus/core/src/client/CommandMenu.tsx +183 -75
  17. package/corpus/core/src/client/agent-chat-adapter.ts +44 -7
  18. package/corpus/core/src/client/analytics.ts +181 -0
  19. package/corpus/core/src/client/changelog/Changelog.tsx +321 -0
  20. package/corpus/core/src/client/client-surface.ts +41 -0
  21. package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
  22. package/corpus/core/src/client/feedback-context.ts +4 -0
  23. package/corpus/core/src/client/index.ts +12 -0
  24. package/corpus/core/src/client/mcp-apps/McpAppRenderer.tsx +54 -0
  25. package/corpus/core/src/client/settings/useBuilderStatus.ts +82 -20
  26. package/corpus/core/src/client/sse-event-processor.ts +7 -1
  27. package/corpus/core/src/mcp/build-server.ts +57 -1
  28. package/corpus/core/src/mcp/embed-app.ts +197 -7
  29. package/corpus/core/src/mcp/oauth-route.ts +109 -13
  30. package/corpus/core/src/server/attribution.ts +211 -0
  31. package/corpus/core/src/server/auth.ts +6 -0
  32. package/corpus/core/src/server/better-auth-instance.ts +49 -5
  33. package/corpus/core/src/server/builder-browser.ts +27 -1
  34. package/corpus/core/src/server/core-routes-plugin.ts +136 -0
  35. package/corpus/core/src/server/google-oauth.ts +19 -6
  36. package/corpus/core/src/server/onboarding-html.ts +35 -0
  37. package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
  38. package/corpus/core/src/styles/agent-conversation.css +21 -0
  39. package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  40. package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  41. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  42. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
  43. package/corpus/templates/analytics/AGENTS.md +8 -1
  44. package/corpus/templates/analytics/CHANGELOG.md +10 -0
  45. package/corpus/templates/analytics/actions/install-dashboard-template.ts +99 -4
  46. package/corpus/templates/analytics/actions/open-traffic-dashboard.ts +1 -1
  47. package/corpus/templates/analytics/actions/update-dashboard.ts +39 -8
  48. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
  49. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +180 -144
  50. package/corpus/templates/analytics/app/components/layout/Header.tsx +2 -3
  51. package/corpus/templates/analytics/app/components/layout/Layout.tsx +7 -5
  52. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +221 -215
  53. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +6 -6
  54. package/corpus/templates/analytics/app/lib/demo-dashboard-path.ts +1 -1
  55. package/corpus/templates/analytics/app/lib/last-opened.ts +2 -1
  56. package/corpus/templates/analytics/app/pages/Settings.tsx +4 -0
  57. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -3
  58. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +2 -2
  59. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
  60. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +101 -99
  61. package/corpus/templates/analytics/app/pages/overview/OverviewPage.tsx +1 -1
  62. package/corpus/templates/analytics/app/routes/adhoc.$id.tsx +18 -5
  63. package/corpus/templates/analytics/app/routes/catalog.tsx +1 -1
  64. package/corpus/templates/analytics/app/routes/dashboards.$id.tsx +9 -0
  65. package/corpus/templates/analytics/app/routes/traffic.tsx +1 -1
  66. package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-06-23-the-dashboards-and-analyses-sidebar-settings-buttons-now-sta.md +6 -0
  69. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +145 -0
  70. package/corpus/templates/analytics/server/db/index.ts +1 -1
  71. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +9 -2
  72. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +1 -1
  73. package/corpus/templates/analytics/server/plugins/agent-chat.ts +2 -2
  74. package/corpus/templates/analytics/server/plugins/core-routes.ts +6 -6
  75. package/corpus/templates/assets/CHANGELOG.md +10 -0
  76. package/corpus/templates/assets/actions/generate-image.ts +24 -0
  77. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
  78. package/corpus/templates/assets/app/global.css +17 -1
  79. package/corpus/templates/assets/app/root.tsx +7 -1
  80. package/corpus/templates/assets/app/routes/_index.tsx +94 -14
  81. package/corpus/templates/assets/app/routes/settings.tsx +6 -0
  82. package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
  83. package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
  84. package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
  85. package/corpus/templates/brain/CHANGELOG.md +10 -0
  86. package/corpus/templates/brain/app/root.tsx +7 -1
  87. package/corpus/templates/brain/app/routes/settings.tsx +8 -1
  88. package/corpus/templates/calendar/CHANGELOG.md +10 -0
  89. package/corpus/templates/calendar/app/pages/Settings.tsx +4 -0
  90. package/corpus/templates/calendar/app/root.tsx +7 -1
  91. package/corpus/templates/chat/CHANGELOG.md +10 -0
  92. package/corpus/templates/chat/app/root.tsx +7 -1
  93. package/corpus/templates/clips/CHANGELOG.md +10 -0
  94. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
  95. package/corpus/templates/clips/app/components/player/share-dialog.tsx +33 -7
  96. package/corpus/templates/clips/app/components/player/sign-in-prompt-dialog.tsx +8 -0
  97. package/corpus/templates/clips/app/components/player/video-player.tsx +150 -20
  98. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
  99. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +7 -11
  100. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +31 -0
  101. package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +71 -0
  102. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
  103. package/corpus/templates/clips/app/root.tsx +7 -1
  104. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +15 -1
  105. package/corpus/templates/clips/app/routes/record.tsx +78 -5
  106. package/corpus/templates/clips/app/routes/share.$shareId.tsx +86 -5
  107. package/corpus/templates/clips/changelog/2026-06-23-clips-shared-in-slack-no-longer-show-a-could-not-start-playb.md +6 -0
  108. package/corpus/templates/clips/changelog/2026-06-23-made-the-play-button-on-shared-and-embedded-clips-scale-with.md +6 -0
  109. package/corpus/templates/clips/changelog/2026-06-23-public-clip-share-links-now-show-whether-they-ll-play-inline.md +6 -0
  110. package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
  111. package/corpus/templates/clips/changelog/2026-06-23-screen-camera-recordings-now-keep-the-presenter-s-face-in-th.md +6 -0
  112. package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
  113. package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
  114. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
  115. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
  116. package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
  117. package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
  118. package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
  119. package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
  120. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
  121. package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
  122. package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
  123. package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
  124. package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
  125. package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
  126. package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
  127. package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
  128. package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
  129. package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
  130. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
  131. package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
  132. package/corpus/templates/clips/desktop/src/lib/recorder.ts +20 -0
  133. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  134. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
  135. package/corpus/templates/clips/desktop/src/overlays/region-record-border.tsx +76 -0
  136. package/corpus/templates/clips/desktop/src/styles.css +119 -2
  137. package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
  138. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +140 -1
  139. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +2 -0
  140. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +4 -1
  141. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +85 -6
  142. package/corpus/templates/clips/shared/share-attribution.ts +79 -0
  143. package/corpus/templates/content/AGENTS.md +20 -3
  144. package/corpus/templates/content/CHANGELOG.md +10 -0
  145. package/corpus/templates/content/actions/_database-utils.ts +15 -0
  146. package/corpus/templates/content/actions/_property-utils.ts +371 -4
  147. package/corpus/templates/content/actions/configure-document-property.ts +35 -1
  148. package/corpus/templates/content/actions/create-content-database.ts +5 -1
  149. package/corpus/templates/content/actions/delete-document-property.ts +52 -2
  150. package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
  151. package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
  152. package/corpus/templates/content/actions/set-document-property.ts +39 -0
  153. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
  154. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
  155. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
  156. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
  157. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
  158. package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
  159. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
  160. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
  161. package/corpus/templates/content/app/global.css +9 -0
  162. package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
  163. package/corpus/templates/content/app/root.tsx +7 -1
  164. package/corpus/templates/content/server/db/schema.ts +30 -0
  165. package/corpus/templates/content/server/plugins/db.ts +87 -0
  166. package/corpus/templates/content/shared/api.ts +7 -0
  167. package/corpus/templates/content/shared/properties.ts +109 -0
  168. package/corpus/templates/design/CHANGELOG.md +10 -0
  169. package/corpus/templates/design/actions/generate-design.ts +4 -0
  170. package/corpus/templates/design/app/root.tsx +7 -1
  171. package/corpus/templates/design/app/routes/settings.tsx +10 -2
  172. package/corpus/templates/dispatch/CHANGELOG.md +10 -0
  173. package/corpus/templates/dispatch/app/root.tsx +7 -1
  174. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
  175. package/corpus/templates/forms/CHANGELOG.md +10 -0
  176. package/corpus/templates/forms/actions/export-responses.ts +6 -0
  177. package/corpus/templates/forms/actions/list-responses.ts +2 -0
  178. package/corpus/templates/forms/actions/response-insights.ts +2 -0
  179. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
  180. package/corpus/templates/forms/app/root.tsx +7 -1
  181. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
  182. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
  183. package/corpus/templates/forms/server/db/schema.ts +7 -0
  184. package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
  185. package/corpus/templates/forms/server/lib/integrations.ts +76 -2
  186. package/corpus/templates/forms/server/plugins/db.ts +20 -0
  187. package/corpus/templates/forms/shared/types.ts +12 -0
  188. package/corpus/templates/macros/CHANGELOG.md +10 -0
  189. package/corpus/templates/macros/app/root.tsx +7 -1
  190. package/corpus/templates/mail/CHANGELOG.md +10 -0
  191. package/corpus/templates/mail/app/components/layout/CommandPalette.tsx +3 -0
  192. package/corpus/templates/mail/app/pages/SettingsPage.tsx +27 -0
  193. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +31 -17
  194. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -4
  195. package/corpus/templates/plan/.agents/skills/visual-recap/SKILL.md +34 -24
  196. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -4
  197. package/corpus/templates/plan/AGENTS.md +4 -0
  198. package/corpus/templates/plan/CHANGELOG.md +10 -0
  199. package/corpus/templates/plan/actions/view-screen.ts +5 -0
  200. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +98 -1
  201. package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
  202. package/corpus/templates/plan/app/pages/PlansPage.tsx +107 -7
  203. package/corpus/templates/plan/app/root.tsx +7 -1
  204. package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
  205. package/corpus/templates/plan/shared/share-attribution.ts +72 -0
  206. package/corpus/templates/slides/CHANGELOG.md +10 -0
  207. package/corpus/templates/slides/app/root.tsx +7 -1
  208. package/corpus/templates/videos/CHANGELOG.md +4 -151
  209. package/corpus/templates/videos/app/root.tsx +7 -1
  210. package/dist/action.js +87 -0
  211. package/dist/action.js.map +1 -1
  212. package/dist/agent/engine/builder-engine.d.ts +1 -1
  213. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  214. package/dist/agent/model-config.d.ts +2 -2
  215. package/dist/agent/model-config.d.ts.map +1 -1
  216. package/dist/agent/model-config.js +0 -2
  217. package/dist/agent/model-config.js.map +1 -1
  218. package/dist/agent/run-loop-with-resume.d.ts +13 -0
  219. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  220. package/dist/agent/run-loop-with-resume.js +44 -0
  221. package/dist/agent/run-loop-with-resume.js.map +1 -1
  222. package/dist/changelog/parse.d.ts +70 -0
  223. package/dist/changelog/parse.d.ts.map +1 -0
  224. package/dist/changelog/parse.js +200 -0
  225. package/dist/changelog/parse.js.map +1 -0
  226. package/dist/cli/changelog.d.ts +4 -0
  227. package/dist/cli/changelog.d.ts.map +1 -0
  228. package/dist/cli/changelog.js +166 -0
  229. package/dist/cli/changelog.js.map +1 -0
  230. package/dist/cli/index.js +18 -0
  231. package/dist/cli/index.js.map +1 -1
  232. package/dist/cli/recap.d.ts.map +1 -1
  233. package/dist/cli/recap.js +22 -1
  234. package/dist/cli/recap.js.map +1 -1
  235. package/dist/cli/skills.d.ts +3 -3
  236. package/dist/cli/skills.d.ts.map +1 -1
  237. package/dist/cli/skills.js +74 -47
  238. package/dist/cli/skills.js.map +1 -1
  239. package/dist/client/AgentPanel.d.ts.map +1 -1
  240. package/dist/client/AgentPanel.js +5 -3
  241. package/dist/client/AgentPanel.js.map +1 -1
  242. package/dist/client/AssistantChat.d.ts +6 -1
  243. package/dist/client/AssistantChat.d.ts.map +1 -1
  244. package/dist/client/AssistantChat.js +2 -2
  245. package/dist/client/AssistantChat.js.map +1 -1
  246. package/dist/client/CommandMenu.d.ts +17 -1
  247. package/dist/client/CommandMenu.d.ts.map +1 -1
  248. package/dist/client/CommandMenu.js +49 -13
  249. package/dist/client/CommandMenu.js.map +1 -1
  250. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  251. package/dist/client/agent-chat-adapter.js +43 -6
  252. package/dist/client/agent-chat-adapter.js.map +1 -1
  253. package/dist/client/analytics.d.ts +18 -0
  254. package/dist/client/analytics.d.ts.map +1 -1
  255. package/dist/client/analytics.js +177 -0
  256. package/dist/client/analytics.js.map +1 -1
  257. package/dist/client/changelog/Changelog.d.ts +47 -0
  258. package/dist/client/changelog/Changelog.d.ts.map +1 -0
  259. package/dist/client/changelog/Changelog.js +138 -0
  260. package/dist/client/changelog/Changelog.js.map +1 -0
  261. package/dist/client/client-surface.d.ts +17 -0
  262. package/dist/client/client-surface.d.ts.map +1 -0
  263. package/dist/client/client-surface.js +24 -0
  264. package/dist/client/client-surface.js.map +1 -0
  265. package/dist/client/composer/TiptapComposer.d.ts +26 -1
  266. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  267. package/dist/client/composer/TiptapComposer.js +27 -5
  268. package/dist/client/composer/TiptapComposer.js.map +1 -1
  269. package/dist/client/feedback-context.d.ts +3 -0
  270. package/dist/client/feedback-context.d.ts.map +1 -1
  271. package/dist/client/feedback-context.js +2 -0
  272. package/dist/client/feedback-context.js.map +1 -1
  273. package/dist/client/index.d.ts +3 -1
  274. package/dist/client/index.d.ts.map +1 -1
  275. package/dist/client/index.js +3 -1
  276. package/dist/client/index.js.map +1 -1
  277. package/dist/client/mcp-apps/McpAppRenderer.d.ts +1 -0
  278. package/dist/client/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  279. package/dist/client/mcp-apps/McpAppRenderer.js +42 -1
  280. package/dist/client/mcp-apps/McpAppRenderer.js.map +1 -1
  281. package/dist/client/settings/useBuilderStatus.d.ts +2 -0
  282. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  283. package/dist/client/settings/useBuilderStatus.js +58 -10
  284. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  285. package/dist/client/sse-event-processor.d.ts.map +1 -1
  286. package/dist/client/sse-event-processor.js +7 -1
  287. package/dist/client/sse-event-processor.js.map +1 -1
  288. package/dist/mcp/build-server.d.ts +21 -0
  289. package/dist/mcp/build-server.d.ts.map +1 -1
  290. package/dist/mcp/build-server.js +43 -1
  291. package/dist/mcp/build-server.js.map +1 -1
  292. package/dist/mcp/embed-app.d.ts.map +1 -1
  293. package/dist/mcp/embed-app.js +197 -7
  294. package/dist/mcp/embed-app.js.map +1 -1
  295. package/dist/mcp/oauth-route.d.ts.map +1 -1
  296. package/dist/mcp/oauth-route.js +95 -12
  297. package/dist/mcp/oauth-route.js.map +1 -1
  298. package/dist/server/attribution.d.ts +83 -0
  299. package/dist/server/attribution.d.ts.map +1 -0
  300. package/dist/server/attribution.js +178 -0
  301. package/dist/server/attribution.js.map +1 -0
  302. package/dist/server/auth.d.ts.map +1 -1
  303. package/dist/server/auth.js +7 -0
  304. package/dist/server/auth.js.map +1 -1
  305. package/dist/server/better-auth-instance.d.ts +9 -1
  306. package/dist/server/better-auth-instance.d.ts.map +1 -1
  307. package/dist/server/better-auth-instance.js +32 -2
  308. package/dist/server/better-auth-instance.js.map +1 -1
  309. package/dist/server/builder-browser.d.ts +4 -0
  310. package/dist/server/builder-browser.d.ts.map +1 -1
  311. package/dist/server/builder-browser.js +20 -1
  312. package/dist/server/builder-browser.js.map +1 -1
  313. package/dist/server/core-routes-plugin.d.ts +25 -0
  314. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  315. package/dist/server/core-routes-plugin.js +101 -0
  316. package/dist/server/core-routes-plugin.js.map +1 -1
  317. package/dist/server/google-oauth.d.ts.map +1 -1
  318. package/dist/server/google-oauth.js +16 -2
  319. package/dist/server/google-oauth.js.map +1 -1
  320. package/dist/server/onboarding-html.d.ts.map +1 -1
  321. package/dist/server/onboarding-html.js +35 -0
  322. package/dist/server/onboarding-html.js.map +1 -1
  323. package/dist/shared/mcp-embed-headers.js +1 -1
  324. package/dist/shared/mcp-embed-headers.js.map +1 -1
  325. package/dist/styles/agent-conversation.css +21 -0
  326. package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  327. package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  328. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  329. package/docs/design/durable-agent-runs.md +217 -0
  330. package/package.json +1 -1
  331. package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  332. package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  333. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  334. package/corpus/templates/analytics/app/pages/About.tsx +0 -108
  335. package/corpus/templates/analytics/app/routes/about.tsx +0 -9
  336. /package/corpus/templates/analytics/app/routes/{dashboards.tsx → dashboards._index.tsx} +0 -0
@@ -0,0 +1,806 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ type PointerEvent as ReactPointerEvent,
8
+ } from "react";
9
+ import { IconChevronRight, IconGripVertical } from "@tabler/icons-react";
10
+ import { cn } from "@/lib/utils";
11
+ import { useDocumentProperties } from "@/hooks/use-document-properties";
12
+ import { useReorderDocumentProperty } from "@/hooks/use-document-properties";
13
+ import { useSetDocumentProperty } from "@/hooks/use-document-properties";
14
+ import {
15
+ blocksRenderMode,
16
+ blocksStorageTarget,
17
+ isBlocksPropertyType,
18
+ isPrimaryBlocksField,
19
+ type BlocksStorageTarget,
20
+ } from "@shared/properties";
21
+ import type { DocumentProperty } from "@shared/api";
22
+ import { VisualEditor } from "./VisualEditor";
23
+ import {
24
+ createBlockFieldSaveController,
25
+ type BlockFieldSaveController,
26
+ } from "./blockFieldSaveController";
27
+ import {
28
+ acquireBlockFieldSaveController,
29
+ blockFieldSaveImplRef,
30
+ peekBlockFieldSaveController,
31
+ releaseBlockFieldSaveController,
32
+ } from "./blockFieldSaveRegistry";
33
+
34
+ const BLOCK_FIELD_DRAG_THRESHOLD = 6;
35
+
36
+ interface DocumentBlockFieldsProps {
37
+ documentId: string;
38
+ canEdit: boolean;
39
+ /**
40
+ * The fully-wired collaborative body editor for the primary "Content" field.
41
+ * Rendered as-is when solo (chromeless) and inside a header/collapsible shell
42
+ * when there are multiple Blocks fields.
43
+ */
44
+ primaryEditor: ReactNode;
45
+ }
46
+
47
+ export function blockFieldsFromProperties(
48
+ properties: DocumentProperty[],
49
+ ): DocumentProperty[] {
50
+ return properties
51
+ .filter((property) => isBlocksPropertyType(property.definition.type))
52
+ .sort((a, b) => a.definition.position - b.definition.position);
53
+ }
54
+
55
+ export type FieldReorderTarget = {
56
+ targetPropertyId: string;
57
+ position: "before" | "after";
58
+ };
59
+
60
+ export function computeFieldReorderTarget(
61
+ draggedId: string,
62
+ dropGapIndex: number,
63
+ orderedFields: DocumentProperty[],
64
+ ): FieldReorderTarget | null {
65
+ const blockFields = orderedFields.filter((field) =>
66
+ isBlocksPropertyType(field.definition.type),
67
+ );
68
+ const draggedIndex = blockFields.findIndex(
69
+ (field) => field.definition.id === draggedId,
70
+ );
71
+
72
+ if (
73
+ draggedIndex === -1 ||
74
+ dropGapIndex < 0 ||
75
+ dropGapIndex > blockFields.length ||
76
+ blockFields.length < 2
77
+ ) {
78
+ return null;
79
+ }
80
+
81
+ if (dropGapIndex === draggedIndex || dropGapIndex === draggedIndex + 1) {
82
+ return null;
83
+ }
84
+
85
+ if (dropGapIndex === blockFields.length) {
86
+ const lastField = blockFields[blockFields.length - 1];
87
+ if (!lastField || lastField.definition.id === draggedId) return null;
88
+ return { targetPropertyId: lastField.definition.id, position: "after" };
89
+ }
90
+
91
+ const followingField = blockFields[dropGapIndex];
92
+ if (!followingField || followingField.definition.id === draggedId) {
93
+ return null;
94
+ }
95
+
96
+ return { targetPropertyId: followingField.definition.id, position: "before" };
97
+ }
98
+
99
+ function blockFieldDragMoved(
100
+ startX: number,
101
+ startY: number,
102
+ clientX: number,
103
+ clientY: number,
104
+ ) {
105
+ return (
106
+ Math.hypot(clientX - startX, clientY - startY) >= BLOCK_FIELD_DRAG_THRESHOLD
107
+ );
108
+ }
109
+
110
+ function suppressNextDocumentClick() {
111
+ const handler = (event: MouseEvent) => {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ };
115
+
116
+ globalThis.document.addEventListener("click", handler, {
117
+ capture: true,
118
+ once: true,
119
+ });
120
+ }
121
+
122
+ type BlockFieldDragPreviewState = {
123
+ label: string;
124
+ x: number;
125
+ y: number;
126
+ width: number;
127
+ };
128
+
129
+ function blockFieldDragPreviewFromElement(
130
+ element: HTMLElement,
131
+ label: string,
132
+ clientX: number,
133
+ clientY: number,
134
+ ): BlockFieldDragPreviewState {
135
+ const rect = element.getBoundingClientRect();
136
+ return {
137
+ label,
138
+ x: clientX,
139
+ y: clientY,
140
+ width: Math.min(rect.width, 320),
141
+ };
142
+ }
143
+
144
+ function BlockFieldDragPreview({
145
+ preview,
146
+ }: {
147
+ preview: BlockFieldDragPreviewState | null;
148
+ }) {
149
+ if (!preview) return null;
150
+
151
+ return (
152
+ <div
153
+ aria-hidden="true"
154
+ className="pointer-events-none fixed left-0 top-0 z-[9999] flex h-8 max-w-80 items-center gap-1.5 overflow-hidden rounded-md border border-border bg-background/95 px-2 text-sm font-medium opacity-80 shadow-lg"
155
+ data-block-field-drag-preview
156
+ style={{
157
+ width: preview.width,
158
+ transform: `translate3d(${preview.x + 12}px, ${preview.y + 10}px, 0)`,
159
+ }}
160
+ >
161
+ <IconGripVertical className="size-4 shrink-0 text-muted-foreground" />
162
+ <span className="truncate">{preview.label}</span>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ // The render decision for a row's Blocks fields, computed from the loaded
168
+ // field list AND whether that list has actually arrived from the server.
169
+ //
170
+ // Three states must stay distinct so we NEVER blindly write the document body
171
+ // when the solo field's identity/primacy is unknown:
172
+ //
173
+ // - "loading" — field data has not arrived yet. The list is `[]` but that
174
+ // does NOT mean zero fields; render a non-editable
175
+ // placeholder, never a writable body editor.
176
+ // - "empty" — loaded, and there are genuinely zero Blocks fields (e.g.
177
+ // the only field was deleted → metadata-only row). Render no
178
+ // block editor (an "add a Blocks field" affordance is fine),
179
+ // NOT the body editor.
180
+ // - "solo" — loaded, exactly one Blocks field. Route to THAT field's
181
+ // store: primary → body editor, non-primary → block-field
182
+ // controller. Solo does NOT imply primary.
183
+ // - "multi" — loaded, 2+ fields. Each gets a header.
184
+ export type BlockFieldsRenderState =
185
+ | { kind: "loading" }
186
+ | { kind: "empty" }
187
+ | { kind: "solo"; field: DocumentProperty; target: BlocksStorageTarget }
188
+ | { kind: "multi"; fields: DocumentProperty[] };
189
+
190
+ // Whether the query data we are holding actually belongs to the CURRENT row.
191
+ // `useDocumentProperties` keeps the previous document's data as placeholder
192
+ // across a documentId change, so identity must be confirmed before the field
193
+ // layout is trusted — otherwise the old doc's solo-primary layout could route
194
+ // the new doc's edits to the body. The response carries its own `documentId`
195
+ // (shared/api.ts → DocumentPropertiesResponse).
196
+ export function isLoadedForDocument(
197
+ documentId: string,
198
+ data: { documentId: string } | undefined,
199
+ ): boolean {
200
+ return data?.documentId === documentId;
201
+ }
202
+
203
+ export function blockFieldsRenderState(args: {
204
+ loaded: boolean;
205
+ blockFields: DocumentProperty[];
206
+ }): BlockFieldsRenderState {
207
+ // Until the field list has arrived we cannot know how many Blocks fields the
208
+ // row has, nor which one is solo, nor whether it is primary. Treat as loading
209
+ // and render a non-editable placeholder — never a body-backed writable editor.
210
+ if (!args.loaded) return { kind: "loading" };
211
+
212
+ const { blockFields } = args;
213
+ if (blockFields.length === 0) return { kind: "empty" };
214
+ if (blocksRenderMode(blockFields.length) === "multi") {
215
+ return { kind: "multi", fields: blockFields };
216
+ }
217
+ const field = blockFields[0]!;
218
+ return {
219
+ kind: "solo",
220
+ field,
221
+ target: blocksStorageTarget(field.definition.options),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Renders all Blocks fields for a database row.
227
+ *
228
+ * - Exactly ONE Blocks field → chromeless: just the editing surface, exactly
229
+ * like the current Notion-style body (no header).
230
+ * - TWO or more → every field shows its name as a header and each is
231
+ * collapsible and reorderable.
232
+ *
233
+ * Solo reversibility: deleting down to one field returns to chromeless but keeps
234
+ * the surviving field's stored name.
235
+ */
236
+ export function DocumentBlockFields({
237
+ documentId,
238
+ canEdit,
239
+ primaryEditor,
240
+ }: DocumentBlockFieldsProps) {
241
+ const query = useDocumentProperties(documentId);
242
+ const properties = query.data?.properties ?? [];
243
+ const blockFields = useMemo(
244
+ () => blockFieldsFromProperties(properties),
245
+ [properties],
246
+ );
247
+
248
+ // Loaded ONLY when the query data we hold actually belongs to the CURRENT
249
+ // documentId. `useDocumentProperties` uses `placeholderData: (prev) => prev`,
250
+ // so right after the viewed row changes, `query.data` still holds the PREVIOUS
251
+ // document's field layout for a tick. Trusting it would let the old doc's
252
+ // solo-primary layout route the NEW doc's edits to the body (clobbering a
253
+ // non-primary field). The response carries its own `documentId`
254
+ // (shared/api.ts → DocumentPropertiesResponse), so we gate on an identity
255
+ // match: until the data is for THIS document, treat the row as still loading
256
+ // (a non-editable placeholder), never a writable body editor.
257
+ const loaded = isLoadedForDocument(documentId, query.data);
258
+ const state = blockFieldsRenderState({ loaded, blockFields });
259
+
260
+ switch (state.kind) {
261
+ case "loading":
262
+ // Field data not yet arrived: render a NON-editable placeholder, never a
263
+ // writable body editor. We do not know yet whether the solo field (if any)
264
+ // is primary, so routing to the body could clobber a non-primary field.
265
+ return (
266
+ <div
267
+ className="grid gap-1"
268
+ data-block-fields-state="loading"
269
+ aria-busy="true"
270
+ >
271
+ <div className="h-24 animate-pulse rounded-md bg-muted/40" />
272
+ </div>
273
+ );
274
+ case "empty":
275
+ // Loaded with zero Blocks fields (the only field was deleted → a
276
+ // metadata-only row). Render NO block editor — definitely not the body
277
+ // editor. An affordance to add a Blocks field is appropriate here.
278
+ return (
279
+ <div className="grid gap-1" data-block-fields-state="empty">
280
+ {canEdit ? (
281
+ <p className="px-1 py-2 text-sm text-muted-foreground">
282
+ No Blocks fields. Add one from the property menu.
283
+ </p>
284
+ ) : null}
285
+ </div>
286
+ );
287
+ case "solo":
288
+ // Solo (chromeless: no header) — but solo does NOT mean primary. Route to
289
+ // WHICHEVER store backs the lone field:
290
+ // - primary → the collaborative body editor (`documents.content`)
291
+ // - non-primary → the debounced block-field-store editor
292
+ if (state.target === "block_field_store") {
293
+ return (
294
+ <div className="grid gap-1" data-block-fields-state="solo">
295
+ <AdditionalBlockEditor
296
+ // Identity key: a documentId/propertyId change unmounts the old
297
+ // instance (flushing its pending save) and mounts a fresh one with
298
+ // a fresh save controller — so the controller can never DISPLAY one
299
+ // field while SAVING to another across an identity change.
300
+ key={`${documentId}:${state.field.definition.id}`}
301
+ documentId={documentId}
302
+ property={state.field}
303
+ canEdit={canEdit}
304
+ />
305
+ </div>
306
+ );
307
+ }
308
+ return (
309
+ <div className="grid gap-1" data-block-fields-state="solo">
310
+ {primaryEditor}
311
+ </div>
312
+ );
313
+ case "multi":
314
+ return (
315
+ <MultiBlockFields
316
+ documentId={documentId}
317
+ canEdit={canEdit}
318
+ blockFields={state.fields}
319
+ primaryEditor={primaryEditor}
320
+ />
321
+ );
322
+ }
323
+ }
324
+
325
+ function MultiBlockFields({
326
+ documentId,
327
+ canEdit,
328
+ blockFields,
329
+ primaryEditor,
330
+ }: {
331
+ documentId: string;
332
+ canEdit: boolean;
333
+ blockFields: DocumentProperty[];
334
+ primaryEditor: ReactNode;
335
+ }) {
336
+ const reorder = useReorderDocumentProperty(documentId);
337
+ const [dragId, setDragId] = useState<string | null>(null);
338
+ const [dragOverGapIndex, setDragOverGapIndex] = useState<number | null>(null);
339
+ const [dragPreview, setDragPreview] =
340
+ useState<BlockFieldDragPreviewState | null>(null);
341
+
342
+ useEffect(() => {
343
+ return () => {
344
+ globalThis.document.body.style.userSelect = "";
345
+ globalThis.document.body.style.cursor = "";
346
+ globalThis.document.body.classList.remove("notion-editor-is-dragging");
347
+ };
348
+ }, []);
349
+
350
+ function clearFieldDrag() {
351
+ setDragId(null);
352
+ setDragOverGapIndex(null);
353
+ setDragPreview(null);
354
+ globalThis.document.body.classList.remove("notion-editor-is-dragging");
355
+ }
356
+
357
+ function dropGapIndexFromPoint(clientX: number, clientY: number) {
358
+ const element = globalThis.document.elementFromPoint(clientX, clientY);
359
+ const gap = element?.closest<HTMLElement>("[data-block-field-drop-zone]");
360
+ const indexText = gap?.dataset.blockFieldDropGapIndex;
361
+ if (!indexText) return null;
362
+
363
+ const index = Number(indexText);
364
+ if (!Number.isInteger(index)) return null;
365
+ return index;
366
+ }
367
+
368
+ function moveFieldToGap(propertyId: string, dropGapIndex: number) {
369
+ const target = computeFieldReorderTarget(
370
+ propertyId,
371
+ dropGapIndex,
372
+ blockFields,
373
+ );
374
+ if (!target) return;
375
+
376
+ void reorder.mutateAsync({
377
+ documentId,
378
+ propertyId,
379
+ targetPropertyId: target.targetPropertyId,
380
+ position: target.position,
381
+ });
382
+ }
383
+
384
+ function startFieldPointerDrag(
385
+ property: DocumentProperty,
386
+ event: ReactPointerEvent<HTMLElement>,
387
+ ) {
388
+ if (!canEdit || blockFields.length < 2) return;
389
+
390
+ event.preventDefault();
391
+ event.stopPropagation();
392
+
393
+ const propertyId = property.definition.id;
394
+ const sourceElement =
395
+ event.currentTarget.closest<HTMLElement>("[data-block-field-shell]") ??
396
+ event.currentTarget;
397
+ const startX = event.clientX;
398
+ const startY = event.clientY;
399
+ let dragging = false;
400
+
401
+ function validGapIndexFromPoint(clientX: number, clientY: number) {
402
+ const gapIndex = dropGapIndexFromPoint(clientX, clientY);
403
+ if (gapIndex === null) return null;
404
+ return computeFieldReorderTarget(propertyId, gapIndex, blockFields)
405
+ ? gapIndex
406
+ : null;
407
+ }
408
+
409
+ function beginDrag(moveEvent: PointerEvent) {
410
+ dragging = true;
411
+ setDragId(propertyId);
412
+ setDragOverGapIndex(null);
413
+ setDragPreview(
414
+ blockFieldDragPreviewFromElement(
415
+ sourceElement,
416
+ property.definition.name,
417
+ moveEvent.clientX,
418
+ moveEvent.clientY,
419
+ ),
420
+ );
421
+ globalThis.document.body.style.userSelect = "none";
422
+ globalThis.document.body.style.cursor = "grabbing";
423
+ globalThis.document.body.classList.add("notion-editor-is-dragging");
424
+ }
425
+
426
+ const handlePointerMove = (moveEvent: PointerEvent) => {
427
+ if (
428
+ !dragging &&
429
+ !blockFieldDragMoved(
430
+ startX,
431
+ startY,
432
+ moveEvent.clientX,
433
+ moveEvent.clientY,
434
+ )
435
+ ) {
436
+ return;
437
+ }
438
+ if (!dragging) beginDrag(moveEvent);
439
+ moveEvent.preventDefault();
440
+ setDragPreview((current) =>
441
+ current
442
+ ? { ...current, x: moveEvent.clientX, y: moveEvent.clientY }
443
+ : current,
444
+ );
445
+ setDragOverGapIndex(
446
+ validGapIndexFromPoint(moveEvent.clientX, moveEvent.clientY),
447
+ );
448
+ };
449
+
450
+ const handlePointerUp = (upEvent: PointerEvent) => {
451
+ globalThis.document.body.style.userSelect = "";
452
+ globalThis.document.body.style.cursor = "";
453
+ globalThis.document.removeEventListener("pointermove", handlePointerMove);
454
+ globalThis.document.removeEventListener("pointerup", handlePointerUp);
455
+
456
+ if (dragging) {
457
+ suppressNextDocumentClick();
458
+ const gapIndex = validGapIndexFromPoint(
459
+ upEvent.clientX,
460
+ upEvent.clientY,
461
+ );
462
+ if (gapIndex !== null) moveFieldToGap(propertyId, gapIndex);
463
+ }
464
+
465
+ clearFieldDrag();
466
+ };
467
+
468
+ globalThis.document.addEventListener("pointermove", handlePointerMove);
469
+ globalThis.document.addEventListener("pointerup", handlePointerUp);
470
+ }
471
+
472
+ return (
473
+ <div className="grid">
474
+ <BlockFieldDragPreview preview={dragPreview} />
475
+ {blockFields.map((property, index) => {
476
+ const primary = isPrimaryBlocksField(property.definition.options);
477
+ return (
478
+ <div key={property.definition.id}>
479
+ <FieldDropZone
480
+ active={canEdit && dragId !== null}
481
+ over={dragOverGapIndex === index}
482
+ gapIndex={index}
483
+ />
484
+ <BlockFieldShell
485
+ property={property}
486
+ canEdit={canEdit}
487
+ isDragging={dragId === property.definition.id}
488
+ onPointerDown={(event) => startFieldPointerDrag(property, event)}
489
+ >
490
+ {primary ? (
491
+ primaryEditor
492
+ ) : (
493
+ <AdditionalBlockEditor
494
+ // Identity key (see solo case): remount on a documentId/
495
+ // propertyId change so a reused instance never saves the new
496
+ // doc's edits to the old field's closure.
497
+ key={`${documentId}:${property.definition.id}`}
498
+ documentId={documentId}
499
+ property={property}
500
+ canEdit={canEdit}
501
+ />
502
+ )}
503
+ </BlockFieldShell>
504
+ </div>
505
+ );
506
+ })}
507
+ <FieldDropZone
508
+ active={canEdit && dragId !== null}
509
+ over={dragOverGapIndex === blockFields.length}
510
+ gapIndex={blockFields.length}
511
+ />
512
+ </div>
513
+ );
514
+ }
515
+
516
+ function FieldDropZone({
517
+ active,
518
+ over,
519
+ gapIndex,
520
+ }: {
521
+ active: boolean;
522
+ over: boolean;
523
+ gapIndex: number;
524
+ }) {
525
+ return (
526
+ <div
527
+ className={cn("relative h-3", active && "cursor-grabbing")}
528
+ data-block-field-drop-zone
529
+ data-block-field-drop-gap-index={gapIndex}
530
+ >
531
+ <div
532
+ className={cn(
533
+ "pointer-events-none absolute left-6 right-2 top-1/2 h-[3px] -translate-y-1/2 rounded-full opacity-0 transition-opacity",
534
+ over && "opacity-100",
535
+ )}
536
+ style={{
537
+ background: "hsl(210 100% 52%)",
538
+ boxShadow: "0 0 0 1px hsl(var(--background))",
539
+ }}
540
+ />
541
+ </div>
542
+ );
543
+ }
544
+
545
+ function BlockFieldShell({
546
+ property,
547
+ canEdit,
548
+ children,
549
+ isDragging,
550
+ onPointerDown,
551
+ }: {
552
+ property: DocumentProperty;
553
+ canEdit: boolean;
554
+ children: ReactNode;
555
+ isDragging: boolean;
556
+ onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
557
+ }) {
558
+ const [open, setOpen] = useState(true);
559
+
560
+ return (
561
+ <section
562
+ className={cn(
563
+ // Borderless, Capacities-style: fields are separated by the header +
564
+ // spacing, not a box. Field drop targets live only in the explicit
565
+ // between-field zones rendered by MultiBlockFields.
566
+ "group/blockfield rounded-md",
567
+ isDragging && "opacity-50",
568
+ )}
569
+ data-block-field-shell
570
+ data-block-field-id={property.definition.id}
571
+ >
572
+ {/* ONE grip rail: the header grip sits in a fixed 24px left gutter, and
573
+ the content below is indented by the same 24px (pl-6). The editor's
574
+ per-block drag handle is 24px wide and positioned just left of the
575
+ block content, so it lands in that same gutter — header grip and block
576
+ grips align on a single vertical rail (Capacities-style). */}
577
+ <div className="flex items-center py-1 pr-2">
578
+ {canEdit ? (
579
+ <span
580
+ role="button"
581
+ aria-label={`Reorder ${property.definition.name}`}
582
+ className="flex w-6 shrink-0 justify-center cursor-grab text-muted-foreground/40 transition-colors hover:text-foreground active:cursor-grabbing"
583
+ onPointerDown={onPointerDown}
584
+ >
585
+ <IconGripVertical className="size-4" />
586
+ </span>
587
+ ) : (
588
+ // Keep the 24px gutter so the header label stays aligned with the
589
+ // content indent even when reordering is disabled.
590
+ <span className="w-6 shrink-0" />
591
+ )}
592
+ <button
593
+ type="button"
594
+ aria-expanded={open}
595
+ aria-label={`Toggle ${property.definition.name}`}
596
+ className="flex min-w-0 flex-1 items-center gap-1 rounded py-0.5 text-left text-sm font-medium text-foreground hover:bg-muted/50"
597
+ onClick={() => setOpen((value) => !value)}
598
+ >
599
+ <IconChevronRight
600
+ className={cn(
601
+ "size-4 shrink-0 text-muted-foreground transition-transform",
602
+ open && "rotate-90",
603
+ )}
604
+ />
605
+ <span className="truncate">{property.definition.name}</span>
606
+ </button>
607
+ </div>
608
+ {open ? (
609
+ <div className="block-field-content pb-3 pl-6 pr-2">{children}</div>
610
+ ) : null}
611
+ </section>
612
+ );
613
+ }
614
+
615
+ /**
616
+ * Owns the save-controller wiring for one ADDITIONAL (non-primary) Blocks field:
617
+ * the debounced single-flight controller, content state, server-adopt effect,
618
+ * and unmount-flush. Extracted from the editor component so this behavior is
619
+ * testable WITHOUT rendering TipTap (the controller, its save target, and the
620
+ * remount/flush semantics are the parts the review flagged).
621
+ *
622
+ * Identity safety: callers mount this under an identity `key`
623
+ * (`${documentId}:${propertyId}`) so a row/field change unmounts the old
624
+ * instance (flushing its pending save to the OLD field) and mounts a fresh one
625
+ * with a fresh controller. The save target is ALSO read through a ref, so the
626
+ * controller never captures a documentId/propertyId that could go stale — the
627
+ * editor can never DISPLAY one field while SAVING to another (CORE alias hole).
628
+ */
629
+ export function useBlockFieldEditor({
630
+ documentId,
631
+ propertyId,
632
+ initialContent,
633
+ save,
634
+ }: {
635
+ documentId: string;
636
+ propertyId: string;
637
+ initialContent: string;
638
+ save: (request: {
639
+ documentId: string;
640
+ propertyId: string;
641
+ value: string;
642
+ }) => Promise<unknown>;
643
+ }): { content: string; onChange: (markdown: string) => void } {
644
+ const key = `${documentId}:${propertyId}`;
645
+
646
+ // The shared controller calls the freshest save impl through a per-key ref. The
647
+ // save TARGET (documentId:propertyId) is fixed by the key; only the function
648
+ // identity changes per mount, never the field it writes to.
649
+ const implRef = blockFieldSaveImplRef(key);
650
+ implRef.current = (value: string) => save({ documentId, propertyId, value });
651
+
652
+ // Build (but do not yet ref-count) the controller factory for this key. The
653
+ // formal acquire/release happens in the effect below; we only need the factory
654
+ // here so the first acquire can create the instance.
655
+ const factory = () =>
656
+ createBlockFieldSaveController({
657
+ initialContent,
658
+ // Single-flight + trailing WITHIN this one shared controller now orders
659
+ // saves across ALL editor instances for the key (there is only ever one
660
+ // controller per key), so no cross-instance serialization lane is needed.
661
+ save: (value) => implRef.current(value),
662
+ onError: (error) =>
663
+ console.error("Failed to save Blocks field content", {
664
+ documentId,
665
+ propertyId,
666
+ error,
667
+ }),
668
+ });
669
+
670
+ // Acquire the ONE shared controller for this field key, and release it on
671
+ // unmount / key change. Across ANY mount/unmount/collapse/reopen interleaving
672
+ // there is exactly one controller per key — one pending value, one in-flight
673
+ // save — so an older save can never overwrite a newer one for the same field,
674
+ // even when an old instance's unmount-flush overlaps a new instance's edit.
675
+ // Release flush-then-evicts: the latest dirty content still persists to THIS
676
+ // field, and a quick reopen before the flush settles re-acquires the SAME
677
+ // controller (no competing instance).
678
+ //
679
+ // `factory`/`implRef` are intentionally not effect deps: the impl ref is updated
680
+ // every render, and the identity key forces a remount for a DIFFERENT key, so
681
+ // within one mount the key is stable and we acquire exactly once.
682
+ const controllerRef = useRef<BlockFieldSaveController | null>(null);
683
+ useEffect(() => {
684
+ controllerRef.current = acquireBlockFieldSaveController(key, factory);
685
+ return () => {
686
+ controllerRef.current = null;
687
+ releaseBlockFieldSaveController(key);
688
+ };
689
+ // eslint-disable-next-line react-hooks/exhaustive-deps
690
+ }, [key]);
691
+
692
+ // Seed the displayed value for this mount. Precedence (controller's freshest
693
+ // known content wins over a stale `initialContent`, server props win only when
694
+ // genuinely newer):
695
+ // 1. dirty controller (`pending !== lastSaved`) → show `pending` (the true
696
+ // latest text the user typed; never clobber an in-progress edit).
697
+ // 2. clean controller that has saved locally (`hasSavedLocally`) but whose
698
+ // `lastSaved` differs from `initialContent` → show `lastSaved`. The server
699
+ // query hasn't refetched the just-saved value yet, so `initialContent` is
700
+ // STALE (older than what we persisted); showing it would flash pre-save
701
+ // content and base the next edit on stale text.
702
+ // 3. otherwise → `initialContent`. Covers no controller yet, a controller
703
+ // already in sync with the server, and a clean controller that never saved
704
+ // locally (so server props are authoritative / possibly newer external).
705
+ const [content, setContent] = useState(() => {
706
+ const existing = peekBlockFieldSaveController(key);
707
+ if (existing) {
708
+ if (existing.pending !== existing.lastSaved) {
709
+ return existing.pending;
710
+ }
711
+ if (existing.hasSavedLocally && existing.lastSaved !== initialContent) {
712
+ return existing.lastSaved;
713
+ }
714
+ }
715
+ return initialContent;
716
+ });
717
+
718
+ // Adopt fresh server content when it is a GENUINELY newer external update (e.g.
719
+ // an agent edit) — not when it is merely stale server props lagging a local
720
+ // save. The controller is acquired in the [key] effect above, which runs first.
721
+ //
722
+ // Adopt only when ALL hold:
723
+ // - server content diverges from what we last confirmed saved
724
+ // (`initialContent !== lastSaved`), AND
725
+ // - the field is clean — the user hasn't typed something newer
726
+ // (`pending === lastSaved`; never clobber a dirty local edit), AND
727
+ // - the controller has NOT just saved locally (`!hasSavedLocally`). If it
728
+ // HAS, its `lastSaved` is content we originated that the server query
729
+ // hasn't refetched yet, so `initialContent` is STALE (older than
730
+ // `lastSaved`) — adopting it would regress to pre-save content. We wait for
731
+ // the server to catch up; once it echoes `lastSaved`, this condition is
732
+ // simply false. `mark()` below clears `hasSavedLocally`, so a later genuine
733
+ // external edit is adopted normally.
734
+ useEffect(() => {
735
+ const controller = controllerRef.current;
736
+ if (!controller) return;
737
+ // Never adopt over a dirty local edit.
738
+ if (controller.pending !== controller.lastSaved) return;
739
+ if (initialContent === controller.lastSaved) {
740
+ // The server has echoed our last-known content — we're back in sync, so
741
+ // server props are no longer "behind" this controller. Clear the
742
+ // just-saved latch so a LATER genuine external edit (e.g. an agent) is
743
+ // adopted normally. (Without this the latch would stick forever after the
744
+ // first save+echo and suppress all future external updates.)
745
+ if (controller.hasSavedLocally) controller.mark(initialContent);
746
+ return;
747
+ }
748
+ // initialContent diverges from lastSaved and the field is clean:
749
+ if (!controller.hasSavedLocally) {
750
+ // Genuine newer external update — adopt it.
751
+ setContent(initialContent);
752
+ controller.mark(initialContent);
753
+ }
754
+ // else: server props are stale, lagging a local save the server hasn't
755
+ // echoed yet. Keep showing lastSaved and wait for the echo above to clear
756
+ // the latch. (Cross-client concurrent same-field edits remain last-write-
757
+ // wins for v1; true coherence needs the deferred server-side versioning.)
758
+ // eslint-disable-next-line react-hooks/exhaustive-deps
759
+ }, [initialContent]);
760
+
761
+ function onChange(markdown: string) {
762
+ setContent(markdown);
763
+ // The controller is acquired in the mount effect; user edits only arrive
764
+ // after mount, so it is present. Guard defensively regardless.
765
+ controllerRef.current?.change(markdown);
766
+ }
767
+
768
+ return { content, onChange };
769
+ }
770
+
771
+ /**
772
+ * Editor for an ADDITIONAL (non-primary) Blocks field. Uses the rich-text
773
+ * VisualEditor without collab (no Yjs) — independently editable, debounced
774
+ * save through set-document-property which persists to the field's own store.
775
+ */
776
+ function AdditionalBlockEditor({
777
+ documentId,
778
+ property,
779
+ canEdit,
780
+ }: {
781
+ documentId: string;
782
+ property: DocumentProperty;
783
+ canEdit: boolean;
784
+ }) {
785
+ const setProperty = useSetDocumentProperty(documentId);
786
+ const propertyId = property.definition.id;
787
+ const initialContent =
788
+ typeof property.value === "string" ? property.value : "";
789
+ const { content, onChange } = useBlockFieldEditor({
790
+ documentId,
791
+ propertyId,
792
+ initialContent,
793
+ save: setProperty.mutateAsync,
794
+ });
795
+
796
+ return (
797
+ <VisualEditor
798
+ key={propertyId}
799
+ documentId={documentId}
800
+ content={content}
801
+ onChange={onChange}
802
+ editable={canEdit}
803
+ localFileMode
804
+ />
805
+ );
806
+ }