@agent-native/core 0.81.3 → 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 (92) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/CommandMenu.tsx +34 -12
  5. package/corpus/core/src/file-upload/builder.ts +199 -37
  6. package/corpus/core/src/file-upload/index.ts +2 -0
  7. package/corpus/core/src/file-upload/types.ts +38 -0
  8. package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
  9. package/corpus/templates/clips/actions/create-recording.ts +44 -0
  10. package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
  11. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
  12. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
  13. package/corpus/templates/clips/app/routes/record.tsx +12 -1
  14. package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
  15. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
  16. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
  17. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
  18. package/corpus/templates/clips/shared/recording-core.ts +5 -0
  19. package/corpus/templates/content/.env.local.example +6 -0
  20. package/corpus/templates/content/AGENTS.md +10 -1
  21. package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
  22. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
  23. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
  24. package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
  25. package/corpus/templates/content/actions/_database-utils.ts +41 -0
  26. package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
  27. package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
  28. package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
  29. package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
  30. package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
  31. package/corpus/templates/content/actions/list-content-databases.ts +41 -33
  32. package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
  33. package/corpus/templates/content/actions/update-document.ts +93 -0
  34. package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
  35. package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
  36. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +105 -17
  37. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
  38. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
  39. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
  40. package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
  41. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
  42. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
  43. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
  44. package/corpus/templates/content/app/global.css +10 -3
  45. package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
  46. package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
  47. package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
  48. package/corpus/templates/content/app/i18n-data.ts +212 -0
  49. package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
  50. package/corpus/templates/content/app/root.tsx +236 -8
  51. package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
  52. package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
  53. package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
  54. package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
  55. package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
  56. package/corpus/templates/content/package.json +3 -1
  57. package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
  58. package/corpus/templates/content/scripts/dev-database.mjs +83 -0
  59. package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
  60. package/corpus/templates/content/server/db/schema.ts +27 -0
  61. package/corpus/templates/content/server/plugins/db.ts +33 -0
  62. package/corpus/templates/content/shared/api.ts +45 -0
  63. package/corpus/templates/content/shared/builder-mdx.ts +905 -5
  64. package/corpus/templates/content/shared/nfm-registry.ts +2 -0
  65. package/corpus/templates/content/shared/source-component-block.ts +141 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
  67. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
  68. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
  69. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
  70. package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
  71. package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
  72. package/dist/client/CommandMenu.d.ts +6 -2
  73. package/dist/client/CommandMenu.d.ts.map +1 -1
  74. package/dist/client/CommandMenu.js +25 -14
  75. package/dist/client/CommandMenu.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/routes.d.ts +1 -1
  79. package/dist/file-upload/builder.d.ts.map +1 -1
  80. package/dist/file-upload/builder.js +137 -25
  81. package/dist/file-upload/builder.js.map +1 -1
  82. package/dist/file-upload/index.d.ts +1 -1
  83. package/dist/file-upload/index.d.ts.map +1 -1
  84. package/dist/file-upload/index.js.map +1 -1
  85. package/dist/file-upload/types.d.ts +26 -0
  86. package/dist/file-upload/types.d.ts.map +1 -1
  87. package/dist/file-upload/types.js.map +1 -1
  88. package/dist/notifications/routes.d.ts +3 -3
  89. package/dist/progress/routes.d.ts +1 -1
  90. package/dist/resources/handlers.d.ts +3 -3
  91. package/dist/server/transcribe-voice.d.ts +1 -1
  92. 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 & {
@@ -22,7 +22,7 @@ import {
22
22
  useRef,
23
23
  useState,
24
24
  } from "react";
25
- import type { ClipboardEvent } from "react";
25
+ import type { ClipboardEvent, MutableRefObject } from "react";
26
26
  import { useNavigate } from "react-router";
27
27
  import { toast } from "sonner";
28
28
 
@@ -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,
@@ -69,6 +70,54 @@ interface DocumentEditorProps {
69
70
  documentId: string;
70
71
  }
71
72
 
73
+ type FieldSaveWatermark = { title: string; updatedAt: string | null };
74
+ type ContentSaveWatermark = { content: string; updatedAt: string | null };
75
+
76
+ function adoptConfirmedSaveWatermarks({
77
+ saved,
78
+ savedAt,
79
+ title,
80
+ content,
81
+ updates,
82
+ lastSavedTitleRef,
83
+ lastSavedContentRef,
84
+ }: {
85
+ saved: Document | undefined;
86
+ savedAt: string;
87
+ title: string;
88
+ content: string;
89
+ updates: {
90
+ title?: string;
91
+ content?: string;
92
+ icon?: string | null;
93
+ };
94
+ lastSavedTitleRef: MutableRefObject<FieldSaveWatermark>;
95
+ lastSavedContentRef: MutableRefObject<ContentSaveWatermark>;
96
+ }) {
97
+ if (updates.title !== undefined) {
98
+ lastSavedTitleRef.current = { title, updatedAt: savedAt };
99
+ } else if (
100
+ (updates.content !== undefined || updates.icon !== undefined) &&
101
+ saved?.title === lastSavedTitleRef.current.title
102
+ ) {
103
+ lastSavedTitleRef.current = {
104
+ ...lastSavedTitleRef.current,
105
+ updatedAt: savedAt,
106
+ };
107
+ }
108
+ if (updates.content !== undefined) {
109
+ lastSavedContentRef.current = { content, updatedAt: savedAt };
110
+ } else if (
111
+ (updates.title !== undefined || updates.icon !== undefined) &&
112
+ saved?.content === lastSavedContentRef.current.content
113
+ ) {
114
+ lastSavedContentRef.current = {
115
+ ...lastSavedContentRef.current,
116
+ updatedAt: savedAt,
117
+ };
118
+ }
119
+ }
120
+
72
121
  function DocumentEditorSkeleton() {
73
122
  return (
74
123
  <div className="flex min-h-0 flex-1 flex-col bg-background">
@@ -282,6 +331,9 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
282
331
  const t = useT();
283
332
  const updateDocument = useUpdateDocument();
284
333
  const queryClient = useQueryClient();
334
+ const processBuilderBodies = useProcessBuilderBodyHydration(
335
+ document.databaseMembership?.databaseDocumentId ?? documentId,
336
+ );
285
337
  const canEdit = document.canEdit ?? true;
286
338
  const canEditRef = useRef(canEdit);
287
339
  canEditRef.current = canEdit;
@@ -314,6 +366,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
314
366
  string | null
315
367
  >(document.updatedAt ?? null);
316
368
  const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
369
+ const promotedBuilderBodyRef = useRef<string | null>(null);
317
370
  const pendingDocumentSaveRef = useRef<PendingDocumentSave | null>(null);
318
371
  // Separate freshness watermarks for title and content so that a content save
319
372
  // never suppresses adopting a newer external title and vice versa.
@@ -344,6 +397,26 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
344
397
  },
345
398
  [t],
346
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]);
347
420
  const titleFocusedRef = useRef(false);
348
421
  const scrollContainerRef = useRef<HTMLDivElement>(null);
349
422
  const titleInputRef = useRef<HTMLTextAreaElement>(null);
@@ -686,12 +759,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
686
759
  const saved = await persistDocumentUpdates(updates, options);
687
760
  // Adopt the server updatedAt per saved field.
688
761
  const savedAt = saved?.updatedAt ?? new Date().toISOString();
689
- if (updates.title !== undefined) {
690
- lastSavedTitleRef.current = { title, updatedAt: savedAt };
691
- }
692
- if (updates.content !== undefined) {
693
- lastSavedContentRef.current = { content, updatedAt: savedAt };
694
- }
762
+ adoptConfirmedSaveWatermarks({
763
+ saved,
764
+ savedAt,
765
+ title,
766
+ content,
767
+ updates,
768
+ lastSavedTitleRef,
769
+ lastSavedContentRef,
770
+ });
695
771
 
696
772
  // Push-on-save: when auto-sync is on, trigger a Notion push
697
773
  // immediately after the save lands in SQL. This eliminates the
@@ -818,15 +894,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
818
894
  if (Object.keys(updates).length > 0) {
819
895
  const saved = await persistDocumentUpdates(updates);
820
896
  const savedAt = saved?.updatedAt ?? new Date().toISOString();
821
- if (updates.title !== undefined) {
822
- lastSavedTitleRef.current = { title, updatedAt: savedAt };
823
- }
824
- if (updates.content !== undefined) {
825
- lastSavedContentRef.current = {
826
- content,
827
- updatedAt: savedAt,
828
- };
829
- }
897
+ adoptConfirmedSaveWatermarks({
898
+ saved,
899
+ savedAt,
900
+ title,
901
+ content,
902
+ updates,
903
+ lastSavedTitleRef,
904
+ lastSavedContentRef,
905
+ });
830
906
  }
831
907
  } finally {
832
908
  // Acknowledge the flush even if nothing changed — the SQL row is
@@ -1113,7 +1189,19 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1113
1189
  }
1114
1190
  onSelect={(emoji) => {
1115
1191
  void (async () => {
1116
- 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
+ });
1117
1205
  })().catch(handleBackgroundSaveError);
1118
1206
  }}
1119
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,