@agent-native/core 0.82.0 → 0.83.0

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 (63) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/CommandMenu.tsx +34 -12
  5. package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
  6. package/corpus/templates/content/.env.local.example +6 -0
  7. package/corpus/templates/content/AGENTS.md +10 -1
  8. package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
  9. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
  10. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
  11. package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
  12. package/corpus/templates/content/actions/_database-utils.ts +41 -0
  13. package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
  14. package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
  15. package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
  16. package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
  17. package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
  18. package/corpus/templates/content/actions/list-content-databases.ts +41 -33
  19. package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
  20. package/corpus/templates/content/actions/update-document.ts +93 -0
  21. package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
  22. package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
  23. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +45 -4
  24. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
  25. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
  26. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
  27. package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
  28. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
  29. package/corpus/templates/content/app/global.css +10 -3
  30. package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
  31. package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
  32. package/corpus/templates/content/app/i18n-data.ts +212 -0
  33. package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
  34. package/corpus/templates/content/app/root.tsx +236 -8
  35. package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
  36. package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
  37. package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
  38. package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
  39. package/corpus/templates/content/package.json +3 -1
  40. package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
  41. package/corpus/templates/content/scripts/dev-database.mjs +83 -0
  42. package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
  43. package/corpus/templates/content/server/db/schema.ts +27 -0
  44. package/corpus/templates/content/server/plugins/db.ts +33 -0
  45. package/corpus/templates/content/shared/api.ts +45 -0
  46. package/corpus/templates/content/shared/builder-mdx.ts +905 -5
  47. package/corpus/templates/content/shared/nfm-registry.ts +2 -0
  48. package/corpus/templates/content/shared/source-component-block.ts +141 -0
  49. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
  51. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
  52. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
  53. package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
  54. package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
  55. package/dist/client/CommandMenu.d.ts +6 -2
  56. package/dist/client/CommandMenu.d.ts.map +1 -1
  57. package/dist/client/CommandMenu.js +25 -14
  58. package/dist/client/CommandMenu.js.map +1 -1
  59. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  60. package/dist/notifications/routes.d.ts +2 -2
  61. package/dist/observability/routes.d.ts +8 -8
  62. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  63. package/package.json +1 -1
@@ -10,6 +10,7 @@ import {
10
10
  parseDocumentHideFromSearch,
11
11
  } from "../server/lib/documents.js";
12
12
  import type { DocumentUpdateResponse } from "../shared/api.js";
13
+ import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js";
13
14
  import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js";
14
15
  import { serializeDocumentSource } from "./_document-source.js";
15
16
  import {
@@ -33,6 +34,60 @@ function canManageRole(role: string) {
33
34
  return role === "owner" || role === "admin";
34
35
  }
35
36
 
37
+ function builderBodyWithoutImageSourceComponentMarkers(
38
+ content: string | null | undefined,
39
+ ) {
40
+ return (content ?? "")
41
+ .replace(/(?:^|\n)<SourceComponent\b[\s\S]*?\/>[ \t]*(?=\n|$)/g, (marker) =>
42
+ marker.includes('componentName="Image"') ? "\n" : marker,
43
+ )
44
+ .replace(/\n{3,}/g, "\n\n")
45
+ .trim();
46
+ }
47
+
48
+ function builderBodyWithoutMarkdownImages(content: string | null | undefined) {
49
+ return (content ?? "")
50
+ .replace(
51
+ /(?:^|\n)!\[(?:\\.|[^\]\\])*\]\(\S+?(?:\s+"[^"]*")?\)[ \t]*(?=\n|$)/g,
52
+ "\n",
53
+ )
54
+ .replace(/\n{3,}/g, "\n\n")
55
+ .trim();
56
+ }
57
+
58
+ function normalizedBuilderBodyProse(content: string | null | undefined) {
59
+ return (content ?? "")
60
+ .normalize("NFKC")
61
+ .replace(/\u00a0/g, " ")
62
+ .replace(/\s+/g, " ")
63
+ .trim();
64
+ }
65
+
66
+ export function isStaleBuilderImageSourceComponentSave(args: {
67
+ incomingContent: string;
68
+ currentContent: string;
69
+ sourceContent: string | null | undefined;
70
+ }) {
71
+ const sourceContent = args.sourceContent ?? "";
72
+ const currentMatchesSource =
73
+ normalizedBuilderBodyProse(args.currentContent) ===
74
+ normalizedBuilderBodyProse(sourceContent);
75
+ if (
76
+ !args.incomingContent.includes('componentName="Image"') ||
77
+ !sourceContent.includes("![") ||
78
+ sourceContent.includes('componentName="Image"') ||
79
+ !currentMatchesSource
80
+ ) {
81
+ return false;
82
+ }
83
+ return (
84
+ normalizedBuilderBodyProse(
85
+ builderBodyWithoutImageSourceComponentMarkers(args.incomingContent),
86
+ ) ===
87
+ normalizedBuilderBodyProse(builderBodyWithoutMarkdownImages(sourceContent))
88
+ );
89
+ }
90
+
36
91
  export default defineAction({
37
92
  description:
38
93
  "Update an existing document's title, content, icon, or favorite status.",
@@ -79,6 +134,44 @@ export default defineAction({
79
134
  content = content.slice(h1Match[0].length).trimStart();
80
135
  }
81
136
  }
137
+ if (
138
+ content.includes('componentName="Image"') &&
139
+ existing.content.includes("![")
140
+ ) {
141
+ const [builderBody] = await db
142
+ .select({
143
+ sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
144
+ })
145
+ .from(schema.contentDatabaseSourceRows)
146
+ .innerJoin(
147
+ schema.contentDatabaseSources,
148
+ eq(
149
+ schema.contentDatabaseSources.id,
150
+ schema.contentDatabaseSourceRows.sourceId,
151
+ ),
152
+ )
153
+ .where(
154
+ and(
155
+ eq(schema.contentDatabaseSourceRows.documentId, id),
156
+ eq(schema.contentDatabaseSources.sourceType, "builder-cms"),
157
+ ),
158
+ )
159
+ .limit(1);
160
+ const sourceValues = JSON.parse(
161
+ builderBody?.sourceValuesJson ?? "{}",
162
+ ) as Record<string, unknown>;
163
+ const sourceContent = sourceValues[BUILDER_CMS_BODY_CONTENT_KEY];
164
+ if (
165
+ typeof sourceContent === "string" &&
166
+ isStaleBuilderImageSourceComponentSave({
167
+ incomingContent: content,
168
+ currentContent: existing.content,
169
+ sourceContent,
170
+ })
171
+ ) {
172
+ content = existing.content;
173
+ }
174
+ }
82
175
  }
83
176
 
84
177
  // Detect actual changes — a no-op call (e.g. the editor echoing back the
@@ -0,0 +1,277 @@
1
+ import type { BlockReadProps, BlockSpec } from "@agent-native/core/blocks";
2
+ import { useT } from "@agent-native/core/client";
3
+ import {
4
+ sourceComponentBlockConfig,
5
+ type SourceComponentData,
6
+ } from "@shared/source-component-block";
7
+ import {
8
+ IconBox,
9
+ IconDatabase,
10
+ IconExternalLink,
11
+ IconPhoto,
12
+ IconLink,
13
+ IconTable,
14
+ } from "@tabler/icons-react";
15
+
16
+ function providerLabel(provider: string) {
17
+ if (provider === "builder") return "Builder";
18
+ return provider.trim() || "Source";
19
+ }
20
+
21
+ function safeHttpUrl(value: string | undefined) {
22
+ if (!value) return null;
23
+ try {
24
+ const url = new URL(value);
25
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
26
+ return url;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function youtubeEmbedUrl(value: string | undefined) {
33
+ const url = safeHttpUrl(value);
34
+ if (!url) return null;
35
+ if (url.hostname === "youtu.be") {
36
+ const id = url.pathname.replace(/^\/+/, "").split("/")[0];
37
+ return id ? `https://www.youtube.com/embed/${id}` : null;
38
+ }
39
+ if (
40
+ url.hostname === "youtube.com" ||
41
+ url.hostname === "www.youtube.com" ||
42
+ url.hostname === "m.youtube.com"
43
+ ) {
44
+ const id = url.searchParams.get("v");
45
+ if (id) return `https://www.youtube.com/embed/${id}`;
46
+ if (url.pathname.startsWith("/embed/")) return url.toString();
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function SourceImagePreview({ url, alt }: { url: string; alt?: string }) {
52
+ return (
53
+ <figure className="mt-2 overflow-hidden rounded-md border bg-background">
54
+ <img
55
+ src={url}
56
+ alt={alt || ""}
57
+ loading="lazy"
58
+ className="max-h-[520px] w-full object-contain"
59
+ />
60
+ {alt ? (
61
+ <figcaption className="border-t px-2.5 py-2 text-xs leading-5 text-muted-foreground">
62
+ {alt}
63
+ </figcaption>
64
+ ) : null}
65
+ </figure>
66
+ );
67
+ }
68
+
69
+ function SourceEmbedPreview({ url }: { url: string }) {
70
+ const embedUrl = youtubeEmbedUrl(url);
71
+ if (embedUrl) {
72
+ return (
73
+ <div className="mt-2 overflow-hidden rounded-md border bg-background">
74
+ <iframe
75
+ data-plan-interactive
76
+ className="aspect-video w-full"
77
+ src={embedUrl}
78
+ title="Builder embedded video"
79
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
80
+ allowFullScreen
81
+ />
82
+ </div>
83
+ );
84
+ }
85
+ return (
86
+ <a
87
+ data-plan-interactive
88
+ className="mt-2 flex min-w-0 items-center gap-2 rounded-md border bg-background px-2.5 py-2 text-xs text-muted-foreground hover:text-foreground"
89
+ href={url}
90
+ target="_blank"
91
+ rel="noreferrer"
92
+ >
93
+ <IconLink className="size-4 shrink-0" />
94
+ <span className="truncate">{url}</span>
95
+ </a>
96
+ );
97
+ }
98
+
99
+ function SourceComponentPreview({ data }: { data: SourceComponentData }) {
100
+ const t = useT();
101
+ const preview = data.preview;
102
+ const previewUrl = preview?.url ?? data.previewUrl;
103
+ const safePreviewUrl = safeHttpUrl(previewUrl)?.toString();
104
+ const imageAlt =
105
+ preview?.fields?.find((field) => field.label.toLowerCase() === "alt")
106
+ ?.value ?? preview?.summary;
107
+ if (data.componentName === "Image" && safePreviewUrl) {
108
+ return <SourceImagePreview url={safePreviewUrl} alt={imageAlt} />;
109
+ }
110
+ if (preview?.kind === "table" && preview.table) {
111
+ return (
112
+ <div className="mt-2 overflow-hidden rounded-md border bg-background text-xs">
113
+ <table className="w-full table-fixed border-collapse">
114
+ <thead className="bg-muted/60 text-muted-foreground">
115
+ <tr>
116
+ {preview.table.columns.map((column) => (
117
+ <th
118
+ key={column.id}
119
+ className="border-b px-2 py-1.5 text-left font-medium"
120
+ >
121
+ <span className="block truncate">{column.label}</span>
122
+ </th>
123
+ ))}
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ {preview.table.rows.length ? (
128
+ preview.table.rows.map((row, index) => (
129
+ <tr key={index} className="border-b last:border-b-0">
130
+ {preview.table?.columns.map((column) => (
131
+ <td key={column.id} className="px-2 py-1.5">
132
+ <span className="block truncate">
133
+ {row[column.id] ?? ""}
134
+ </span>
135
+ </td>
136
+ ))}
137
+ </tr>
138
+ ))
139
+ ) : (
140
+ <tr>
141
+ <td
142
+ className="px-2 py-2 text-muted-foreground"
143
+ colSpan={Math.max(preview.table.columns.length, 1)}
144
+ >
145
+ {t("editor.sourceComponent.noPreviewRows")}
146
+ </td>
147
+ </tr>
148
+ )}
149
+ </tbody>
150
+ </table>
151
+ {preview.table.truncated ? (
152
+ <div className="border-t px-2 py-1.5 text-muted-foreground">
153
+ {t("editor.sourceComponent.previewTruncated")}
154
+ </div>
155
+ ) : null}
156
+ </div>
157
+ );
158
+ }
159
+ if (preview?.kind === "embed" && preview.url) {
160
+ return <SourceEmbedPreview url={preview.url} />;
161
+ }
162
+ if (preview?.fields?.length) {
163
+ return (
164
+ <div className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
165
+ {preview.fields.map((field) => (
166
+ <span
167
+ key={`${field.label}:${field.value}`}
168
+ className="rounded border bg-background px-1.5 py-0.5"
169
+ >
170
+ {field.label}: {field.value}
171
+ </span>
172
+ ))}
173
+ </div>
174
+ );
175
+ }
176
+ if (data.previewKind === "table") {
177
+ return (
178
+ <div className="mt-2 flex flex-wrap items-center gap-2 rounded-md border bg-background px-2.5 py-2 text-xs text-muted-foreground">
179
+ <IconTable className="size-4 shrink-0" />
180
+ {(data.previewItems?.length ? data.previewItems : ["Table"]).map(
181
+ (item) => (
182
+ <span
183
+ key={item}
184
+ className="rounded border bg-muted/40 px-1.5 py-0.5"
185
+ >
186
+ {item}
187
+ </span>
188
+ ),
189
+ )}
190
+ </div>
191
+ );
192
+ }
193
+ if (data.previewKind === "embed" && data.previewUrl) {
194
+ return <SourceEmbedPreview url={data.previewUrl} />;
195
+ }
196
+ if (data.previewItems?.length) {
197
+ return (
198
+ <div className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
199
+ {data.previewItems.map((item) => (
200
+ <span
201
+ key={item}
202
+ className="rounded border bg-background px-1.5 py-0.5"
203
+ >
204
+ {item}
205
+ </span>
206
+ ))}
207
+ </div>
208
+ );
209
+ }
210
+ return null;
211
+ }
212
+
213
+ export function SourceComponentRead({
214
+ data,
215
+ blockId,
216
+ }: BlockReadProps<SourceComponentData>) {
217
+ const t = useT();
218
+ const label =
219
+ data.title ||
220
+ data.componentName ||
221
+ t("editor.sourceComponent.defaultTitle");
222
+ const status = data.preview?.status ?? data.previewStatus ?? "unavailable";
223
+ const statusLabel =
224
+ status === "available"
225
+ ? t("editor.sourceComponent.previewAvailable")
226
+ : status === "warning"
227
+ ? t("editor.sourceComponent.needsAttention")
228
+ : t("editor.sourceComponent.previewUnavailable");
229
+
230
+ return (
231
+ <section
232
+ data-block-id={blockId}
233
+ data-plan-interactive
234
+ className="an-block rounded-md border border-dashed bg-muted/20 px-3 py-3 text-sm"
235
+ >
236
+ <div className="flex min-w-0 items-start justify-between gap-3">
237
+ <div className="flex min-w-0 items-start gap-2">
238
+ {data.componentName === "Image" ? (
239
+ <IconPhoto className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
240
+ ) : (
241
+ <IconBox className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
242
+ )}
243
+ <div className="min-w-0">
244
+ <div className="truncate font-medium text-foreground">{label}</div>
245
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
246
+ <span className="inline-flex items-center gap-1">
247
+ <IconDatabase className="size-3.5" />
248
+ {providerLabel(data.provider)}
249
+ </span>
250
+ <span>{data.componentName}</span>
251
+ {data.sourceLabel ? <span>{data.sourceLabel}</span> : null}
252
+ </div>
253
+ </div>
254
+ </div>
255
+ <span className="shrink-0 rounded-full border bg-background px-2 py-0.5 text-[11px] text-muted-foreground">
256
+ {statusLabel}
257
+ </span>
258
+ </div>
259
+ {data.summary ? (
260
+ <div className="mt-2 text-sm leading-6 text-muted-foreground">
261
+ {data.summary}
262
+ </div>
263
+ ) : null}
264
+ <SourceComponentPreview data={data} />
265
+ <div className="mt-2 flex min-w-0 items-center gap-1 truncate text-[11px] text-muted-foreground">
266
+ <IconExternalLink className="size-3.5 shrink-0" />
267
+ <span className="truncate">{data.rawRef}</span>
268
+ </div>
269
+ </section>
270
+ );
271
+ }
272
+
273
+ export const sourceComponentBlock: BlockSpec<SourceComponentData> = {
274
+ ...sourceComponentBlockConfig,
275
+ Read: SourceComponentRead,
276
+ icon: IconBox,
277
+ };
@@ -26,6 +26,7 @@ import {
26
26
  ContentBlockMarkdownEditor,
27
27
  } from "./ContentBlockMarkdown";
28
28
  import { inlineDatabaseBlock } from "./InlineDatabaseBlock";
29
+ import { sourceComponentBlock } from "./SourceComponentBlock";
29
30
 
30
31
  /**
31
32
  * Content's BROWSER block registry. Registers the same structured-block library
@@ -58,6 +59,7 @@ registerLibraryBlocks(contentBlockRegistry, {
58
59
  for (const block of builderDocsBlocks) {
59
60
  contentBlockRegistry.register(block);
60
61
  }
62
+ contentBlockRegistry.register(sourceComponentBlock);
61
63
  contentBlockRegistry.register(inlineDatabaseBlock);
62
64
 
63
65
  type ContentBlockRenderContext = BlockRenderContext & {
@@ -34,6 +34,7 @@ import { Button } from "@/components/ui/button";
34
34
  import { Sheet, SheetContent } from "@/components/ui/sheet";
35
35
  import { Skeleton } from "@/components/ui/skeleton";
36
36
  import { useComments } from "@/hooks/use-comments";
37
+ import { useProcessBuilderBodyHydration } from "@/hooks/use-content-database";
37
38
  import {
38
39
  useDocument,
39
40
  useDocuments,
@@ -85,14 +86,18 @@ function adoptConfirmedSaveWatermarks({
85
86
  savedAt: string;
86
87
  title: string;
87
88
  content: string;
88
- updates: Record<string, string>;
89
+ updates: {
90
+ title?: string;
91
+ content?: string;
92
+ icon?: string | null;
93
+ };
89
94
  lastSavedTitleRef: MutableRefObject<FieldSaveWatermark>;
90
95
  lastSavedContentRef: MutableRefObject<ContentSaveWatermark>;
91
96
  }) {
92
97
  if (updates.title !== undefined) {
93
98
  lastSavedTitleRef.current = { title, updatedAt: savedAt };
94
99
  } else if (
95
- updates.content !== undefined &&
100
+ (updates.content !== undefined || updates.icon !== undefined) &&
96
101
  saved?.title === lastSavedTitleRef.current.title
97
102
  ) {
98
103
  lastSavedTitleRef.current = {
@@ -103,7 +108,7 @@ function adoptConfirmedSaveWatermarks({
103
108
  if (updates.content !== undefined) {
104
109
  lastSavedContentRef.current = { content, updatedAt: savedAt };
105
110
  } else if (
106
- updates.title !== undefined &&
111
+ (updates.title !== undefined || updates.icon !== undefined) &&
107
112
  saved?.content === lastSavedContentRef.current.content
108
113
  ) {
109
114
  lastSavedContentRef.current = {
@@ -326,6 +331,9 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
326
331
  const t = useT();
327
332
  const updateDocument = useUpdateDocument();
328
333
  const queryClient = useQueryClient();
334
+ const processBuilderBodies = useProcessBuilderBodyHydration(
335
+ document.databaseMembership?.databaseDocumentId ?? documentId,
336
+ );
329
337
  const canEdit = document.canEdit ?? true;
330
338
  const canEditRef = useRef(canEdit);
331
339
  canEditRef.current = canEdit;
@@ -358,6 +366,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
358
366
  string | null
359
367
  >(document.updatedAt ?? null);
360
368
  const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
369
+ const promotedBuilderBodyRef = useRef<string | null>(null);
361
370
  const pendingDocumentSaveRef = useRef<PendingDocumentSave | null>(null);
362
371
  // Separate freshness watermarks for title and content so that a content save
363
372
  // never suppresses adopting a newer external title and vice versa.
@@ -388,6 +397,26 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
388
397
  },
389
398
  [t],
390
399
  );
400
+
401
+ useEffect(() => {
402
+ const membership = document.databaseMembership;
403
+ const hydration = membership?.bodyHydration;
404
+ if (
405
+ !membership?.sourceId ||
406
+ !hydration ||
407
+ (hydration.status !== "pending" && hydration.status !== "error")
408
+ ) {
409
+ return;
410
+ }
411
+ const promotionKey = `${membership.sourceId}:${documentId}:${hydration.status}:${hydration.version ?? ""}`;
412
+ if (promotedBuilderBodyRef.current === promotionKey) return;
413
+ promotedBuilderBodyRef.current = promotionKey;
414
+ processBuilderBodies.mutate({
415
+ sourceId: membership.sourceId,
416
+ documentId,
417
+ limit: 1,
418
+ });
419
+ }, [document.databaseMembership, documentId, processBuilderBodies.mutate]);
391
420
  const titleFocusedRef = useRef(false);
392
421
  const scrollContainerRef = useRef<HTMLDivElement>(null);
393
422
  const titleInputRef = useRef<HTMLTextAreaElement>(null);
@@ -1160,7 +1189,19 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1160
1189
  }
1161
1190
  onSelect={(emoji) => {
1162
1191
  void (async () => {
1163
- await persistDocumentUpdates({ icon: emoji });
1192
+ const saved = await persistDocumentUpdates({
1193
+ icon: emoji,
1194
+ });
1195
+ adoptConfirmedSaveWatermarks({
1196
+ saved,
1197
+ savedAt:
1198
+ saved?.updatedAt ?? new Date().toISOString(),
1199
+ title: localTitleRef.current,
1200
+ content: localContentRef.current,
1201
+ updates: { icon: emoji },
1202
+ lastSavedTitleRef,
1203
+ lastSavedContentRef,
1204
+ });
1164
1205
  })().catch(handleBackgroundSaveError);
1165
1206
  }}
1166
1207
  />
@@ -66,7 +66,10 @@ import {
66
66
  type NotionPageLink,
67
67
  } from "./extensions/NotionExtensions";
68
68
  import { notionFidelityExtensions } from "./extensions/NotionFidelity";
69
- import { RegistryBlockNode } from "./extensions/registryBlocks";
69
+ import {
70
+ LockedSourceComponentBlocks,
71
+ RegistryBlockNode,
72
+ } from "./extensions/registryBlocks";
70
73
  import { VideoNode } from "./extensions/VideoNode";
71
74
  import {
72
75
  getImageFiles,
@@ -1307,6 +1310,7 @@ export function createVisualEditorExtensions({
1307
1310
  // `VisualEditor` below. Mounted after the Notion nodes and before the
1308
1311
  // Markdown extension so the NFM <-> doc round-trip recognizes the node.
1309
1312
  RegistryBlockNode,
1313
+ LockedSourceComponentBlocks,
1310
1314
  ContentReferenceNode.configure({
1311
1315
  currentPath: localFilePath ?? null,
1312
1316
  referenceDepth,
@@ -151,6 +151,7 @@ import {
151
151
  useExecuteBuilderSourceExecution,
152
152
  useMoveDatabaseItem,
153
153
  usePrepareBuilderSourceReview,
154
+ useProcessBuilderBodyHydration,
154
155
  useRefreshContentDatabaseSource,
155
156
  useSetContentDatabaseSourceWriteMode,
156
157
  useSuggestSourceJoinKey,
@@ -479,6 +480,7 @@ function DatabaseTable({
479
480
  const changeSourceRole = useChangeContentDatabaseSourceRole(document.id);
480
481
  const refreshSource = useRefreshContentDatabaseSource(document.id);
481
482
  const disconnectSource = useDisconnectContentDatabaseSource(document.id);
483
+ const processBuilderBodies = useProcessBuilderBodyHydration(document.id);
482
484
  const prepareBuilderReview = usePrepareBuilderSourceReview(document.id);
483
485
  const executeBuilderExecution = useExecuteBuilderSourceExecution(document.id);
484
486
  const setSourceWriteMode = useSetContentDatabaseSourceWriteMode(document.id);
@@ -1570,6 +1572,9 @@ function DatabaseTable({
1570
1572
  sourceId,
1571
1573
  })
1572
1574
  }
1575
+ onHydrateBuilderBodies={(sourceId) =>
1576
+ processBuilderBodies.mutate({ sourceId })
1577
+ }
1573
1578
  onDisconnectSource={(sourceId) =>
1574
1579
  disconnectSource.mutate(
1575
1580
  {
@@ -1635,6 +1640,7 @@ function DatabaseTable({
1635
1640
  attachSource.isPending ||
1636
1641
  changeSourceRole.isPending ||
1637
1642
  refreshSource.isPending ||
1643
+ processBuilderBodies.isPending ||
1638
1644
  disconnectSource.isPending ||
1639
1645
  prepareBuilderReview.isPending ||
1640
1646
  executeBuilderExecution.isPending ||
@@ -3539,6 +3545,7 @@ function DatabaseSettingsPanelSheet({
3539
3545
  onChangeSourceRole,
3540
3546
  onDisconnectSecondary,
3541
3547
  onRefreshSource,
3548
+ onHydrateBuilderBodies,
3542
3549
  onDisconnectSource,
3543
3550
  onReviewBuilderUpdate,
3544
3551
  onSetBuilderLiveWrites,
@@ -3580,6 +3587,7 @@ function DatabaseSettingsPanelSheet({
3580
3587
  ) => Promise<ContentDatabaseResponse>;
3581
3588
  onDisconnectSecondary: (sourceId: string) => void;
3582
3589
  onRefreshSource: (sourceId?: string) => void;
3590
+ onHydrateBuilderBodies: (sourceId: string) => void;
3583
3591
  onDisconnectSource: (sourceId?: string) => void;
3584
3592
  onReviewBuilderUpdate: () => void;
3585
3593
  onSetBuilderLiveWrites: (enabled: boolean) => void;
@@ -3677,6 +3685,7 @@ function DatabaseSettingsPanelSheet({
3677
3685
  setSourceNavStack([]);
3678
3686
  }}
3679
3687
  onRefreshSource={onRefreshSource}
3688
+ onHydrateBuilderBodies={onHydrateBuilderBodies}
3680
3689
  onDisconnectSource={onDisconnectSource}
3681
3690
  onReviewBuilderUpdate={onReviewBuilderUpdate}
3682
3691
  onSetBuilderLiveWrites={onSetBuilderLiveWrites}
@@ -3976,6 +3985,7 @@ function DatabaseSettingsSourcePanel({
3976
3985
  onChangeSourceRole,
3977
3986
  onDisconnectSecondary,
3978
3987
  onRefreshSource,
3988
+ onHydrateBuilderBodies,
3979
3989
  onDisconnectSource,
3980
3990
  onReviewBuilderUpdate,
3981
3991
  onSetBuilderLiveWrites,
@@ -4004,6 +4014,7 @@ function DatabaseSettingsSourcePanel({
4004
4014
  ) => Promise<ContentDatabaseResponse>;
4005
4015
  onDisconnectSecondary: (sourceId: string) => void;
4006
4016
  onRefreshSource: (sourceId?: string) => void;
4017
+ onHydrateBuilderBodies: (sourceId: string) => void;
4007
4018
  onDisconnectSource: (sourceId?: string) => void;
4008
4019
  onReviewBuilderUpdate: () => void;
4009
4020
  onSetBuilderLiveWrites: (enabled: boolean) => void;
@@ -4442,6 +4453,15 @@ function DatabaseSettingsSourcePanel({
4442
4453
  </div>
4443
4454
  ) : null}
4444
4455
 
4456
+ {isBuilderSource && source.bodyHydration ? (
4457
+ <BuilderBodyHydrationCard
4458
+ source={source}
4459
+ canEdit={canEdit}
4460
+ pending={sourceActionPending}
4461
+ onHydrate={() => onHydrateBuilderBodies(source.id)}
4462
+ />
4463
+ ) : null}
4464
+
4445
4465
  {isCodeMode ? (
4446
4466
  <>
4447
4467
  <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3 text-sm">
@@ -5173,6 +5193,78 @@ function SourceRoleCard({
5173
5193
  );
5174
5194
  }
5175
5195
 
5196
+ function BuilderBodyHydrationCard({
5197
+ source,
5198
+ canEdit,
5199
+ pending,
5200
+ onHydrate,
5201
+ }: {
5202
+ source: ContentDatabaseSource;
5203
+ canEdit: boolean;
5204
+ pending: boolean;
5205
+ onHydrate: () => void;
5206
+ }) {
5207
+ const summary = source.bodyHydration;
5208
+ if (!summary || summary.total === 0) return null;
5209
+ const activeCount = summary.pending + summary.hydrating;
5210
+ const needsWork = activeCount > 0 || summary.error > 0;
5211
+ const hydratedLabel = dbText("builderBodiesHydrated", {
5212
+ hydrated: summary.hydrated,
5213
+ total: summary.total,
5214
+ });
5215
+ const detail = needsWork
5216
+ ? [
5217
+ activeCount > 0
5218
+ ? dbText("builderBodiesQueued", { count: activeCount })
5219
+ : null,
5220
+ summary.error > 0
5221
+ ? dbText("builderBodySyncFailed", { count: summary.error })
5222
+ : null,
5223
+ ]
5224
+ .filter(Boolean)
5225
+ .join(" · ")
5226
+ : dbText("builderBodiesReadyLocally");
5227
+
5228
+ return (
5229
+ <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3">
5230
+ <div className="flex min-w-0 items-start justify-between gap-3">
5231
+ <div className="min-w-0">
5232
+ <div className="text-xs font-medium">{dbText("builderBodySync")}</div>
5233
+ <div className="mt-0.5 text-xs text-muted-foreground">
5234
+ {hydratedLabel}
5235
+ </div>
5236
+ </div>
5237
+ {needsWork ? (
5238
+ <Button
5239
+ type="button"
5240
+ size="sm"
5241
+ variant="outline"
5242
+ className="h-7 shrink-0 px-2 text-xs"
5243
+ disabled={!canEdit || pending}
5244
+ onClick={onHydrate}
5245
+ >
5246
+ {pending ? (
5247
+ <Spinner className="mr-1 size-3.5" />
5248
+ ) : (
5249
+ <IconRefresh className="mr-1 size-3.5" />
5250
+ )}
5251
+ {summary.error > 0 ? dbText("retry") : dbText("resume")}
5252
+ </Button>
5253
+ ) : null}
5254
+ </div>
5255
+ <div className="h-1.5 overflow-hidden rounded-full bg-muted">
5256
+ <div
5257
+ className="h-full rounded-full bg-foreground transition-[width]"
5258
+ style={{
5259
+ width: `${Math.round((summary.hydrated / summary.total) * 100)}%`,
5260
+ }}
5261
+ />
5262
+ </div>
5263
+ <div className="break-words text-xs text-muted-foreground">{detail}</div>
5264
+ </div>
5265
+ );
5266
+ }
5267
+
5176
5268
  const DETAIL_FIELD_NAME_HINTS = [
5177
5269
  "name",
5178
5270
  "title",