@agent-native/core 0.70.0 → 0.70.2

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 (172) hide show
  1. package/README.md +15 -15
  2. package/corpus/README.md +2 -2
  3. package/corpus/core/CHANGELOG.md +67 -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/cli/skills.ts +2 -2
  10. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +275 -0
  11. package/corpus/core/src/client/AgentPanel.tsx +5 -3
  12. package/corpus/core/src/client/AssistantChat.tsx +8 -0
  13. package/corpus/core/src/client/client-surface.ts +41 -0
  14. package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
  15. package/corpus/core/src/client/feedback-context.ts +4 -0
  16. package/corpus/core/src/client/index.ts +1 -0
  17. package/corpus/core/src/client/sse-event-processor.ts +7 -1
  18. package/corpus/core/src/mcp/build-server.ts +57 -1
  19. package/corpus/core/src/mcp/embed-app.ts +86 -2
  20. package/corpus/core/src/mcp/oauth-route.ts +109 -13
  21. package/corpus/core/src/server/core-routes-plugin.ts +78 -0
  22. package/corpus/core/src/server/onboarding-html.ts +35 -0
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  24. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
  25. package/corpus/templates/analytics/AGENTS.md +8 -1
  26. package/corpus/templates/analytics/actions/install-dashboard-template.ts +97 -2
  27. package/corpus/templates/analytics/actions/update-dashboard.ts +36 -7
  28. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
  29. package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
  30. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -4
  31. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +0 -3
  32. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
  33. package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
  35. package/corpus/templates/assets/actions/generate-image.ts +24 -0
  36. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
  37. package/corpus/templates/assets/app/global.css +17 -1
  38. package/corpus/templates/assets/app/routes/_index.tsx +94 -14
  39. package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
  40. package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
  41. package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
  42. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
  43. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
  44. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
  45. package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
  51. package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
  52. package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
  53. package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
  54. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
  55. package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
  56. package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
  57. package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
  58. package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
  59. package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
  60. package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
  61. package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
  62. package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
  63. package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
  64. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
  65. package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
  66. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
  67. package/corpus/templates/clips/desktop/src/styles.css +70 -2
  68. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +73 -0
  69. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +34 -5
  70. package/corpus/templates/content/AGENTS.md +20 -3
  71. package/corpus/templates/content/actions/_database-utils.ts +15 -0
  72. package/corpus/templates/content/actions/_property-utils.ts +371 -4
  73. package/corpus/templates/content/actions/configure-document-property.ts +35 -1
  74. package/corpus/templates/content/actions/create-content-database.ts +5 -1
  75. package/corpus/templates/content/actions/delete-document-property.ts +52 -2
  76. package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
  77. package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
  78. package/corpus/templates/content/actions/set-document-property.ts +39 -0
  79. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
  80. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
  81. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
  82. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
  83. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
  84. package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
  85. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
  86. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
  87. package/corpus/templates/content/app/global.css +9 -0
  88. package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
  89. package/corpus/templates/content/server/db/schema.ts +30 -0
  90. package/corpus/templates/content/server/plugins/db.ts +87 -0
  91. package/corpus/templates/content/shared/api.ts +7 -0
  92. package/corpus/templates/content/shared/properties.ts +109 -0
  93. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
  94. package/corpus/templates/forms/actions/export-responses.ts +6 -0
  95. package/corpus/templates/forms/actions/list-responses.ts +2 -0
  96. package/corpus/templates/forms/actions/response-insights.ts +2 -0
  97. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
  98. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
  99. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
  100. package/corpus/templates/forms/server/db/schema.ts +7 -0
  101. package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
  102. package/corpus/templates/forms/server/lib/integrations.ts +76 -2
  103. package/corpus/templates/forms/server/plugins/db.ts +20 -0
  104. package/corpus/templates/forms/shared/types.ts +12 -0
  105. package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
  106. package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
  107. package/dist/action.js +87 -0
  108. package/dist/action.js.map +1 -1
  109. package/dist/agent/engine/builder-engine.d.ts +1 -1
  110. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  111. package/dist/agent/model-config.d.ts +2 -2
  112. package/dist/agent/model-config.d.ts.map +1 -1
  113. package/dist/agent/model-config.js +0 -2
  114. package/dist/agent/model-config.js.map +1 -1
  115. package/dist/agent/run-loop-with-resume.d.ts +13 -0
  116. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  117. package/dist/agent/run-loop-with-resume.js +44 -0
  118. package/dist/agent/run-loop-with-resume.js.map +1 -1
  119. package/dist/cli/skills.js +2 -2
  120. package/dist/cli/skills.js.map +1 -1
  121. package/dist/cli/sync-builder-starter-manifest.d.ts +32 -0
  122. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -0
  123. package/dist/cli/sync-builder-starter-manifest.js +179 -0
  124. package/dist/cli/sync-builder-starter-manifest.js.map +1 -0
  125. package/dist/client/AgentPanel.d.ts.map +1 -1
  126. package/dist/client/AgentPanel.js +5 -3
  127. package/dist/client/AgentPanel.js.map +1 -1
  128. package/dist/client/AssistantChat.d.ts +6 -1
  129. package/dist/client/AssistantChat.d.ts.map +1 -1
  130. package/dist/client/AssistantChat.js +2 -2
  131. package/dist/client/AssistantChat.js.map +1 -1
  132. package/dist/client/client-surface.d.ts +17 -0
  133. package/dist/client/client-surface.d.ts.map +1 -0
  134. package/dist/client/client-surface.js +24 -0
  135. package/dist/client/client-surface.js.map +1 -0
  136. package/dist/client/composer/TiptapComposer.d.ts +26 -1
  137. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  138. package/dist/client/composer/TiptapComposer.js +27 -5
  139. package/dist/client/composer/TiptapComposer.js.map +1 -1
  140. package/dist/client/feedback-context.d.ts +3 -0
  141. package/dist/client/feedback-context.d.ts.map +1 -1
  142. package/dist/client/feedback-context.js +2 -0
  143. package/dist/client/feedback-context.js.map +1 -1
  144. package/dist/client/index.d.ts +1 -0
  145. package/dist/client/index.d.ts.map +1 -1
  146. package/dist/client/index.js +1 -0
  147. package/dist/client/index.js.map +1 -1
  148. package/dist/client/sse-event-processor.d.ts.map +1 -1
  149. package/dist/client/sse-event-processor.js +7 -1
  150. package/dist/client/sse-event-processor.js.map +1 -1
  151. package/dist/mcp/build-server.d.ts +21 -0
  152. package/dist/mcp/build-server.d.ts.map +1 -1
  153. package/dist/mcp/build-server.js +43 -1
  154. package/dist/mcp/build-server.js.map +1 -1
  155. package/dist/mcp/embed-app.d.ts.map +1 -1
  156. package/dist/mcp/embed-app.js +86 -2
  157. package/dist/mcp/embed-app.js.map +1 -1
  158. package/dist/mcp/oauth-route.d.ts.map +1 -1
  159. package/dist/mcp/oauth-route.js +95 -12
  160. package/dist/mcp/oauth-route.js.map +1 -1
  161. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  162. package/dist/server/core-routes-plugin.js +63 -0
  163. package/dist/server/core-routes-plugin.js.map +1 -1
  164. package/dist/server/onboarding-html.d.ts.map +1 -1
  165. package/dist/server/onboarding-html.js +35 -0
  166. package/dist/server/onboarding-html.js.map +1 -1
  167. package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  168. package/docs/design/durable-agent-runs.md +217 -0
  169. package/package.json +1 -1
  170. package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  171. package/corpus/templates/analytics/app/pages/About.tsx +0 -108
  172. package/corpus/templates/analytics/app/routes/about.tsx +0 -9
@@ -0,0 +1,132 @@
1
+ // Module-level registry of ONE save controller per PEEK document id.
2
+ //
3
+ // PROBLEM (the rebase race class — what the per-doc lane could NOT fix):
4
+ // The row peek renders a SINGLE `DatabaseItemPreview` instance whose `item`
5
+ // prop changes on row-switch (it has no per-row `key`, so it does not remount).
6
+ // The old design gave it ONE controller whose target document id was REBASED on
7
+ // every row-switch, plus a shared cross-doc serialization lane. That produced
8
+ // two timing bugs no patch could fully close:
9
+ // - lane queue-jump: a `running`/`tail` microtask gap let a new enqueue
10
+ // dispatch synchronously AHEAD of already-queued saves; and
11
+ // - stale completion after rebase: an OLD-row in-flight save resolving AFTER
12
+ // the row-switch `mark()` overwrote the SHARED controller's `lastSaved` with
13
+ // the old payload and triggered a redundant save against the NEW row.
14
+ // Both are properties of sharing one mutable-target controller across rows.
15
+ //
16
+ // FIX (one controller per document id, ref-counted — mirrors blockFieldSaveRegistry):
17
+ // There is exactly ONE controller per document id. A controller's target doc id
18
+ // is fixed at creation and NEVER changes. Switching rows RELEASES the old doc's
19
+ // controller (whose release flushes its pending save synchronously, bound to its
20
+ // own id) and ACQUIRES the new doc's controller. No `saveTargetIdRef` rebase on
21
+ // a live controller; no cross-doc shared mutable target; no cross-doc lane.
22
+ //
23
+ // With per-doc controllers each is single-flight + trailing for ITS doc only:
24
+ // - A stale completion can only ever advance ITS OWN controller's `lastSaved`
25
+ // (correct) — never another row's. The stale-completion-after-rebase bug is
26
+ // structurally impossible.
27
+ // - Single-flight per doc means at most one save in flight per id, so there is
28
+ // nothing left for a serialization lane to order. The lane is deleted.
29
+ //
30
+ // - acquire(id, factory): returns the SAME controller for a doc id, creating it
31
+ // once via `factory` and bumping a ref-count.
32
+ // - release(id): decrements the ref-count. At 0 we do NOT evict immediately: we
33
+ // flush-then-evict. The final flush still dispatches synchronously bound to
34
+ // this doc id, and a quick reopen BEFORE the flush settles re-acquires the
35
+ // SAME instance (eviction cancelled) — so a quick reopen reuses the live
36
+ // controller and there is never a competing second controller for the id.
37
+
38
+ import type { PreviewDocumentSaveController } from "./previewDocumentSaveController";
39
+
40
+ interface Entry {
41
+ controller: PreviewDocumentSaveController;
42
+ refCount: number;
43
+ // Set while a flush-then-evict is pending after refCount hit 0. If a reopen
44
+ // re-acquires before the flush settles, we clear this so the entry is NOT
45
+ // evicted out from under the live instance.
46
+ evicting: boolean;
47
+ }
48
+
49
+ const registry = new Map<string, Entry>();
50
+
51
+ function payloadsEqual(
52
+ a: PreviewDocumentSaveController["pending"],
53
+ b: PreviewDocumentSaveController["pending"],
54
+ ) {
55
+ return a.title === b.title && a.content === b.content;
56
+ }
57
+
58
+ function controllerIsDirty(controller: PreviewDocumentSaveController): boolean {
59
+ return !payloadsEqual(controller.pending, controller.lastSaved);
60
+ }
61
+
62
+ /**
63
+ * Acquire the controller for `documentId`, creating it once via `factory`.
64
+ * Increments the ref-count and cancels any in-progress eviction so a reopen
65
+ * reuses the live instance rather than racing a fresh one.
66
+ */
67
+ export function acquirePreviewDocumentSaveController(
68
+ documentId: string,
69
+ factory: () => PreviewDocumentSaveController,
70
+ ): PreviewDocumentSaveController {
71
+ let entry = registry.get(documentId);
72
+ if (!entry) {
73
+ entry = { controller: factory(), refCount: 0, evicting: false };
74
+ registry.set(documentId, entry);
75
+ }
76
+ entry.refCount += 1;
77
+ // A reopen before a pending eviction settled: keep the instance alive.
78
+ entry.evicting = false;
79
+ return entry.controller;
80
+ }
81
+
82
+ /**
83
+ * Return the EXISTING controller for `documentId`, or undefined if none is
84
+ * registered. Does NOT create an entry and does NOT change the ref-count.
85
+ */
86
+ export function peekPreviewDocumentSaveController(
87
+ documentId: string,
88
+ ): PreviewDocumentSaveController | undefined {
89
+ return registry.get(documentId)?.controller;
90
+ }
91
+
92
+ /**
93
+ * Release one reference to the controller for `documentId`. When the last
94
+ * reference is released we flush-then-evict: flush the latest dirty payload so a
95
+ * debounce that hadn't fired is not dropped (dispatched synchronously, bound to
96
+ * THIS doc id), then remove the entry ONLY if it is still unreferenced after the
97
+ * flush settles (a reopen during the flush re-acquires the same instance and
98
+ * cancels the eviction).
99
+ */
100
+ export function releasePreviewDocumentSaveController(documentId: string): void {
101
+ const entry = registry.get(documentId);
102
+ if (!entry) return;
103
+ entry.refCount -= 1;
104
+ if (entry.refCount > 0) return;
105
+
106
+ entry.evicting = true;
107
+ const settle = () => {
108
+ const current = registry.get(documentId);
109
+ // Evict only if it is the SAME entry, still unreferenced, and still marked
110
+ // for eviction (a reopen would have flipped `evicting` off / refCount up).
111
+ if (current === entry && current.refCount === 0 && current.evicting) {
112
+ if (controllerIsDirty(current.controller)) {
113
+ current.evicting = false;
114
+ return;
115
+ }
116
+ registry.delete(documentId);
117
+ }
118
+ };
119
+ // flush() dispatches the final save synchronously (so it lands before any
120
+ // teardown/navigation) and resolves once it has settled, before we drop state.
121
+ Promise.resolve(entry.controller.flush()).then(settle, settle);
122
+ }
123
+
124
+ /** Test-only: how many controllers the registry currently holds. */
125
+ export function activePreviewControllerCount(): number {
126
+ return registry.size;
127
+ }
128
+
129
+ /** Test-only: reset the registry between tests. */
130
+ export function __resetPreviewDocumentSaveRegistry(): void {
131
+ registry.clear();
132
+ }
@@ -296,6 +296,15 @@
296
296
  min-height: max(320px, 50vh);
297
297
  }
298
298
 
299
+ /* Block fields stack multiple editors, so each surface uses a compact
300
+ min-height instead of the full-page max(320px, 50vh) — otherwise every field
301
+ reserves ~half the viewport and the gap between fields balloons. 6rem is the
302
+ Tailwind `min-h-24` step (~70% smaller than 320px). */
303
+ .block-field-content .visual-editor-wrapper,
304
+ .block-field-content .visual-editor-wrapper .notion-editor {
305
+ min-height: 6rem;
306
+ }
307
+
299
308
  .notion-editor > *:first-child {
300
309
  margin-top: 0;
301
310
  }
@@ -5,6 +5,7 @@ import type {
5
5
  DeleteDocumentPropertyRequest,
6
6
  DocumentPropertiesResponse,
7
7
  DuplicateDocumentPropertyRequest,
8
+ ReorderDocumentPropertyRequest,
8
9
  SetDocumentPropertyRequest,
9
10
  } from "@shared/api";
10
11
 
@@ -79,6 +80,26 @@ export function useDuplicateDocumentProperty(documentId: string) {
79
80
  });
80
81
  }
81
82
 
83
+ export function useReorderDocumentProperty(documentId: string) {
84
+ const queryClient = useQueryClient();
85
+ return useActionMutation<
86
+ DocumentPropertiesResponse,
87
+ ReorderDocumentPropertyRequest
88
+ >("reorder-document-property", {
89
+ onSuccess: () => {
90
+ queryClient.invalidateQueries({
91
+ queryKey: ["action", "list-document-properties", { documentId }],
92
+ });
93
+ queryClient.invalidateQueries({
94
+ queryKey: ["action", "get-document", { id: documentId }],
95
+ });
96
+ queryClient.invalidateQueries({
97
+ queryKey: ["action", "get-content-database"],
98
+ });
99
+ },
100
+ });
101
+ }
102
+
82
103
  export function useDeleteDocumentProperty(documentId: string) {
83
104
  const queryClient = useQueryClient();
84
105
  return useActionMutation<
@@ -102,6 +102,17 @@ export const contentDatabases = table("content_databases", {
102
102
  documentId: text("document_id").notNull(),
103
103
  title: text("title").notNull().default("Untitled database"),
104
104
  viewConfigJson: text("view_config_json").notNull().default("{}"),
105
+ // Single source of truth for the primary "Content" Blocks field — the one
106
+ // backed by `documents.content`. A DB-enforced single-primary invariant: at
107
+ // most one property id lives here, so two concurrent seeds can never produce
108
+ // two aliasing primaries. NULL means there is currently no primary Blocks
109
+ // field (never seeded, or the primary was intentionally deleted).
110
+ primaryBlocksPropertyId: text("primary_blocks_property_id"),
111
+ // 1 once a database has been seeded with its primary Blocks field at least
112
+ // once. Distinguishes "never seeded" (legacy database needing backfill) from
113
+ // "primary intentionally deleted" (seeded once, then removed — must NOT be
114
+ // reseeded). See delete-document-property.
115
+ blocksSeeded: integer("blocks_seeded").notNull().default(0),
105
116
  createdAt: text("created_at").notNull().default(now()),
106
117
  updatedAt: text("updated_at").notNull().default(now()),
107
118
  });
@@ -243,4 +254,23 @@ export const documentPropertyValues = table("document_property_values", {
243
254
  updatedAt: text("updated_at").notNull().default(now()),
244
255
  });
245
256
 
257
+ // Independent backing store for ADDITIONAL "Blocks" property fields. The
258
+ // default/primary Blocks field ("Content") is backed by `documents.content`
259
+ // (so the existing TipTap/Yjs editor, collab, and existing data migrate for
260
+ // free). Every other Blocks field on a row gets its OWN content here, keyed by
261
+ // (documentId, propertyId) — guaranteeing no two Blocks fields ever alias the
262
+ // same content. Stored as markdown, same shape as `documents.content`.
263
+ export const documentBlockFieldContents = table(
264
+ "document_block_field_contents",
265
+ {
266
+ id: text("id").primaryKey(),
267
+ ownerEmail: text("owner_email").notNull().default("local@localhost"),
268
+ documentId: text("document_id").notNull(),
269
+ propertyId: text("property_id").notNull(),
270
+ content: text("content").notNull().default(""),
271
+ createdAt: text("created_at").notNull().default(now()),
272
+ updatedAt: text("updated_at").notNull().default(now()),
273
+ },
274
+ );
275
+
246
276
  export const documentShares = createSharesTable("document_shares");
@@ -1,4 +1,17 @@
1
1
  import { runMigrations } from "@agent-native/core/db";
2
+ import { repairUnseededBlocksFields } from "../../actions/_property-utils.js";
3
+
4
+ function scheduleBlocksRepairRetry(attempt = 1): void {
5
+ const delayMs = Math.min(30_000 * attempt, 5 * 60_000);
6
+ const timeout = setTimeout(() => {
7
+ repairUnseededBlocksFields().catch(() => {
8
+ if (attempt < 5) scheduleBlocksRepairRetry(attempt + 1);
9
+ });
10
+ }, delayMs);
11
+ if (typeof timeout === "object" && "unref" in timeout) {
12
+ timeout.unref();
13
+ }
14
+ }
2
15
 
3
16
  const runContentMigrations = runMigrations(
4
17
  [
@@ -418,6 +431,68 @@ const runContentMigrations = runMigrations(
418
431
  CREATE INDEX IF NOT EXISTS content_database_source_executions_change_set_idx ON content_database_source_executions (change_set_id);
419
432
  CREATE INDEX IF NOT EXISTS content_database_source_executions_idempotency_idx ON content_database_source_executions (idempotency_key)`,
420
433
  },
434
+ {
435
+ // Independent backing store for ADDITIONAL "Blocks" property fields. The
436
+ // primary "Content" Blocks field is backed by documents.content; every
437
+ // other Blocks field on a row stores its own content here, keyed by
438
+ // (document_id, property_id), so no two Blocks fields ever share content.
439
+ version: 48,
440
+ sql: `CREATE TABLE IF NOT EXISTS document_block_field_contents (
441
+ id TEXT PRIMARY KEY,
442
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
443
+ document_id TEXT NOT NULL,
444
+ property_id TEXT NOT NULL,
445
+ content TEXT NOT NULL DEFAULT '',
446
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
447
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
448
+ )`,
449
+ },
450
+ {
451
+ version: 49,
452
+ sql: `CREATE INDEX IF NOT EXISTS document_block_field_contents_document_idx ON document_block_field_contents (document_id);
453
+ CREATE UNIQUE INDEX IF NOT EXISTS document_block_field_contents_doc_prop_idx ON document_block_field_contents (document_id, property_id)`,
454
+ },
455
+ // v50-v52: DB-enforced single-primary Blocks invariant. `primary_blocks_property_id`
456
+ // is the one source of truth for which property backs `documents.content`;
457
+ // `blocks_seeded` records that a database was seeded once, so an intentionally
458
+ // deleted primary is never silently recreated. Both are additive and safe on
459
+ // existing data.
460
+ {
461
+ version: 50,
462
+ sql: `ALTER TABLE content_databases ADD COLUMN IF NOT EXISTS primary_blocks_property_id TEXT`,
463
+ },
464
+ {
465
+ version: 51,
466
+ sql: `ALTER TABLE content_databases ADD COLUMN IF NOT EXISTS blocks_seeded INTEGER NOT NULL DEFAULT 0`,
467
+ },
468
+ // v52: one-time backfill for LEGACY databases that already had a primary
469
+ // "Content" Blocks definition (seeded by the previous read-path safety net)
470
+ // before these columns existed. Point primary_blocks_property_id at that
471
+ // definition and mark the database seeded. Idempotent: only fills rows that
472
+ // are still NULL, and re-running is a no-op. Databases with NO primary
473
+ // definition are intentionally left unseeded — the startup repair seeds them
474
+ // exactly once via the authenticated path. The correlated subquery picks the
475
+ // primary definition by its options JSON marker (`"primary":true`); the
476
+ // simple `%...%` LIKE is portable across SQLite and Postgres.
477
+ {
478
+ version: 52,
479
+ sql: `UPDATE content_databases
480
+ SET primary_blocks_property_id = (
481
+ SELECT d.id FROM document_property_definitions d
482
+ WHERE d.database_id = content_databases.id
483
+ AND d.type = 'blocks'
484
+ AND d.options_json LIKE '%"primary":true%'
485
+ LIMIT 1
486
+ ),
487
+ blocks_seeded = 1
488
+ WHERE primary_blocks_property_id IS NULL
489
+ AND EXISTS (
490
+ SELECT 1 FROM document_property_definitions d
491
+ WHERE d.database_id = content_databases.id
492
+ AND d.type = 'blocks'
493
+ AND d.options_json LIKE '%"primary":true%'
494
+ )`,
495
+ },
421
496
  ],
422
497
  { table: "content_migrations" },
423
498
  );
@@ -453,4 +528,16 @@ export default async function contentDatabasePlugin(
453
528
  ) {
454
529
  await runContentMigrations(nitroApp);
455
530
  await runContentSourceMigrations(nitroApp);
531
+ // One-time, boot-time repair: seed the primary "Content" Blocks field for any
532
+ // legacy database that has never been seeded (blocks_seeded = 0). Idempotent —
533
+ // the atomic claim in seedDefaultBlocksField makes re-runs no-ops, and it
534
+ // never runs from a read path, so opening a shared/legacy row stays a pure
535
+ // read. Failures here must not crash boot; migrations themselves succeeded.
536
+ try {
537
+ await repairUnseededBlocksFields();
538
+ } catch {
539
+ // Retry in-process so a transient boot-time repair failure does not leave
540
+ // legacy databases without their primary Blocks field until a full reboot.
541
+ scheduleBlocksRepairRetry();
542
+ }
456
543
  }
@@ -194,6 +194,13 @@ export interface DeleteDocumentPropertyRequest {
194
194
  propertyId: string;
195
195
  }
196
196
 
197
+ export interface ReorderDocumentPropertyRequest {
198
+ documentId: string;
199
+ propertyId: string;
200
+ targetPropertyId: string;
201
+ position?: "before" | "after";
202
+ }
203
+
197
204
  export interface ContentDatabase {
198
205
  id: string;
199
206
  documentId: string;
@@ -13,6 +13,11 @@ export const EDITABLE_DOCUMENT_PROPERTY_TYPES = [
13
13
  "email",
14
14
  "phone",
15
15
  "relation",
16
+ // Capacities-style rich-text body field. Each Blocks field is its OWN
17
+ // independent content (NOT an alias of the page body). The default "Content"
18
+ // field is backed by `documents.content`; additional Blocks fields each get
19
+ // their own row in `document_block_field_contents`.
20
+ "blocks",
16
21
  ] as const;
17
22
 
18
23
  export const COMPUTED_DOCUMENT_PROPERTY_TYPES = [
@@ -44,6 +49,7 @@ export const CREATABLE_DOCUMENT_PROPERTY_TYPES = [
44
49
  "url",
45
50
  "email",
46
51
  "phone",
52
+ "blocks",
47
53
  "id",
48
54
  "created_time",
49
55
  "created_by",
@@ -91,6 +97,13 @@ export interface DocumentPropertyOptions {
91
97
  relation?: {
92
98
  databaseId?: string | null;
93
99
  };
100
+ // Set on the default/primary "Content" Blocks field. The primary field is the
101
+ // one whose content is backed by `documents.content` (the page body editor).
102
+ // Exactly one Blocks field per database should be primary; additional Blocks
103
+ // fields store their content independently in `document_block_field_contents`.
104
+ blocks?: {
105
+ primary?: boolean;
106
+ };
94
107
  rollup?: {
95
108
  relationPropertyId?: string | null;
96
109
  targetPropertyId?: string | null;
@@ -137,6 +150,7 @@ export const DOCUMENT_PROPERTY_TYPE_LABELS: Record<
137
150
  email: "Email",
138
151
  phone: "Phone",
139
152
  relation: "Relation",
153
+ blocks: "Blocks",
140
154
  formula: "Formula",
141
155
  rollup: "Rollup",
142
156
  id: "ID",
@@ -161,6 +175,88 @@ export function isComputedPropertyType(
161
175
  return (COMPUTED_DOCUMENT_PROPERTY_TYPES as readonly string[]).includes(type);
162
176
  }
163
177
 
178
+ // The default name a database's seeded Blocks field gets.
179
+ export const DEFAULT_BLOCKS_FIELD_NAME = "Content";
180
+
181
+ export function isBlocksPropertyType(type: DocumentPropertyType): boolean {
182
+ return type === "blocks";
183
+ }
184
+
185
+ // The primary Blocks field is the one backed by `documents.content`. There is
186
+ // at most one per database; it is the field seeded by default and is what the
187
+ // page-body editor reads/writes.
188
+ export function isPrimaryBlocksField(
189
+ options: DocumentPropertyOptions,
190
+ ): boolean {
191
+ return options.blocks?.primary === true;
192
+ }
193
+
194
+ // Word count for a Blocks field's markdown content. Strips the lightest layer
195
+ // of markdown punctuation so a "412 words" table cell reflects prose, not
196
+ // syntax. Kept dependency-free because `shared/properties` is bundled into the
197
+ // browser.
198
+ export function countWords(content: string | null | undefined): number {
199
+ const text = (content ?? "")
200
+ // Drop fenced code blocks wholesale — they're not prose.
201
+ .replace(/```[\s\S]*?```/g, " ")
202
+ // Strip inline/other markdown punctuation that would split or pad tokens.
203
+ .replace(/[#>*_`~\-+=|[\]()!]/g, " ")
204
+ .replace(/\s+/g, " ")
205
+ .trim();
206
+ if (!text) return 0;
207
+ return text.split(" ").filter(Boolean).length;
208
+ }
209
+
210
+ // "412 words" / "1 word" / "Empty" for table cells.
211
+ export function formatWordCount(content: string | null | undefined): string {
212
+ const count = countWords(content);
213
+ if (count === 0) return "Empty";
214
+ return `${count.toLocaleString()} ${count === 1 ? "word" : "words"}`;
215
+ }
216
+
217
+ // Render decision for a set of Blocks-field types on one row:
218
+ // - 0 or 1 Blocks field → "solo" (chromeless: no header, just the body).
219
+ // - 2+ → "multi" (each field gets a header + is collapsible).
220
+ export function blocksRenderMode(blocksFieldCount: number): "solo" | "multi" {
221
+ return blocksFieldCount >= 2 ? "multi" : "solo";
222
+ }
223
+
224
+ // Whether deleting a property triggers the "only Blocks field" warning — i.e.
225
+ // it is a Blocks field and it is the last one in the type, so removing it drops
226
+ // the body for every object of this type.
227
+ export function isOnlyBlocksFieldDeletion(args: {
228
+ type: DocumentPropertyType;
229
+ blocksFieldCount: number;
230
+ }): boolean {
231
+ return isBlocksPropertyType(args.type) && args.blocksFieldCount <= 1;
232
+ }
233
+
234
+ // Where a Blocks field's content is stored. The primary "Content" field lives
235
+ // on `documents.content` (the body); every other Blocks field has its own row
236
+ // in the block-field content store. This single decision keeps reads and writes
237
+ // in lockstep and guarantees no two fields ever share a backing location.
238
+ export type BlocksStorageTarget = "document_body" | "block_field_store";
239
+
240
+ export function blocksStorageTarget(
241
+ options: DocumentPropertyOptions,
242
+ ): BlocksStorageTarget {
243
+ return isPrimaryBlocksField(options) ? "document_body" : "block_field_store";
244
+ }
245
+
246
+ // Resolve a Blocks field's value for one row given the document body and the
247
+ // (additional) block-field content store. Each field reads from exactly one
248
+ // place — the primary from the body, others from their own keyed entry — so two
249
+ // Blocks fields can never resolve to the same content.
250
+ export function resolveBlocksFieldValue(args: {
251
+ options: DocumentPropertyOptions;
252
+ documentBody: string | null | undefined;
253
+ blockFieldContent: string | null | undefined;
254
+ }): string {
255
+ return blocksStorageTarget(args.options) === "document_body"
256
+ ? (args.documentBody ?? "")
257
+ : (args.blockFieldContent ?? "");
258
+ }
259
+
164
260
  export function defaultPropertyOptions(
165
261
  type: DocumentPropertyType,
166
262
  ): DocumentPropertyOptions {
@@ -198,6 +294,12 @@ export function defaultPropertyOptions(
198
294
  };
199
295
  }
200
296
 
297
+ // A manually-added Blocks field is independent (non-primary). The seeded
298
+ // default "Content" field is marked primary explicitly at seed time.
299
+ if (type === "blocks") {
300
+ return { blocks: { primary: false } };
301
+ }
302
+
201
303
  return {};
202
304
  }
203
305
 
@@ -221,6 +323,10 @@ export function parsePropertyOptions(
221
323
  : null,
222
324
  }
223
325
  : undefined,
326
+ blocks:
327
+ parsed.blocks && typeof parsed.blocks === "object"
328
+ ? { primary: parsed.blocks.primary === true }
329
+ : undefined,
224
330
  rollup:
225
331
  parsed.rollup && typeof parsed.rollup === "object"
226
332
  ? {
@@ -436,6 +542,9 @@ export function normalizePropertyValue(
436
542
  case "url":
437
543
  case "email":
438
544
  case "phone":
545
+ // A Blocks field's value is its markdown content — a plain string, same
546
+ // shape as `documents.content`.
547
+ case "blocks":
439
548
  return String(value);
440
549
  case "date":
441
550
  return normalizeDatePropertyValue(value);
@@ -72,10 +72,20 @@ Each response is stored in the `responses` SQL table:
72
72
  | `submittedAt` | text | ISO timestamp |
73
73
  | `ip` | text | Submitter IP when available |
74
74
  | `submitterEmail` | text | Submitter email hint when known |
75
+ | `pageUrl` | text | Page the respondent was on, if sent |
76
+ | `clientSurface` | text | App surface: web, electron, or tauri |
75
77
 
76
78
  `submitterEmail` may come from the logged-in Forms session or from trusted
77
79
  feedback clients that pass the logged-in user email as submission metadata.
78
80
 
81
+ `pageUrl` and `clientSurface` are hidden pass-through fields: trusted embeds
82
+ (e.g. the framework FeedbackButton) forward the URL of the page the respondent
83
+ was on and the runtime shell they were in (`web`, `electron`, or `tauri`) as
84
+ submission metadata, so owners can see which screen and which app feedback came
85
+ from. Both are null for direct fills that send no context, and `clientSurface`
86
+ is allowlisted server-side (unknown values are dropped). The responses table
87
+ surfaces them as "Page" and "Source" columns when any response carries them.
88
+
79
89
  The `data` JSON maps field IDs to values:
80
90
 
81
91
  ```json
@@ -35,6 +35,8 @@ export default defineAction({
35
35
  id: r.id,
36
36
  submittedAt: r.submittedAt,
37
37
  submitterEmail: r.submitterEmail ?? null,
38
+ pageUrl: r.pageUrl ?? null,
39
+ clientSurface: r.clientSurface ?? null,
38
40
  ...JSON.parse(r.data),
39
41
  }));
40
42
  fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
@@ -43,6 +45,8 @@ export default defineAction({
43
45
  "ID",
44
46
  "Submitted At",
45
47
  "Submitter Email",
48
+ "Page URL",
49
+ "Source",
46
50
  ...fields.map((f: any) => f.label),
47
51
  ];
48
52
  const rows = responses.map((r) => {
@@ -51,6 +55,8 @@ export default defineAction({
51
55
  r.id,
52
56
  r.submittedAt,
53
57
  r.submitterEmail ?? "",
58
+ r.pageUrl ?? "",
59
+ r.clientSurface ?? "",
54
60
  ...fields.map((f: any) => {
55
61
  const val = data[f.id];
56
62
  if (Array.isArray(val)) return val.join("; ");
@@ -49,6 +49,8 @@ export default defineAction({
49
49
  data: JSON.parse(r.data),
50
50
  submittedAt: r.submittedAt,
51
51
  submitterEmail: r.submitterEmail ?? null,
52
+ pageUrl: r.pageUrl ?? null,
53
+ clientSurface: r.clientSurface ?? null,
52
54
  })) as FormResponse[],
53
55
  total: (total as any)?.count ?? 0,
54
56
  fields: JSON.parse(form.fields),
@@ -318,6 +318,8 @@ export default defineAction({
318
318
  submittedAt: schema.responses.submittedAt,
319
319
  ip: schema.responses.ip,
320
320
  submitterEmail: schema.responses.submitterEmail,
321
+ pageUrl: schema.responses.pageUrl,
322
+ clientSurface: schema.responses.clientSurface,
321
323
  })
322
324
  .from(schema.responses)
323
325
  .where(responseFilter)