@agent-native/core 0.101.7 → 0.101.10

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 (153) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +22 -0
  3. package/corpus/core/docs/content/deployment.mdx +9 -7
  4. package/corpus/core/docs/content/locales/ar-SA/deployment.mdx +9 -7
  5. package/corpus/core/docs/content/locales/de-DE/deployment.mdx +9 -7
  6. package/corpus/core/docs/content/locales/es-ES/deployment.mdx +9 -7
  7. package/corpus/core/docs/content/locales/fr-FR/deployment.mdx +9 -7
  8. package/corpus/core/docs/content/locales/hi-IN/deployment.mdx +9 -7
  9. package/corpus/core/docs/content/locales/ja-JP/deployment.mdx +9 -7
  10. package/corpus/core/docs/content/locales/ko-KR/deployment.mdx +9 -7
  11. package/corpus/core/docs/content/locales/pt-BR/deployment.mdx +9 -7
  12. package/corpus/core/docs/content/locales/zh-CN/deployment.mdx +9 -7
  13. package/corpus/core/docs/content/locales/zh-TW/deployment.mdx +9 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  16. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  17. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  18. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  19. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  20. package/corpus/core/src/server/auth.ts +25 -1
  21. package/corpus/core/src/server/builder-browser.ts +353 -1
  22. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  23. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  24. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  25. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  26. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  27. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  28. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  29. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  30. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  31. package/corpus/templates/content/AGENTS.md +3 -2
  32. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  33. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  34. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  35. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  36. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  37. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  38. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  39. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  40. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  41. package/corpus/templates/content/actions/_database-source-utils.ts +1041 -154
  42. package/corpus/templates/content/actions/_database-utils.ts +89 -12
  43. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  44. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  45. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  46. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  47. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  48. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  49. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  50. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  51. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  52. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  53. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  54. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +27 -15
  55. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  56. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  57. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2353 -576
  58. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  59. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  60. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  61. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  62. package/corpus/templates/content/app/global.css +18 -1
  63. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  64. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  65. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -0
  66. package/corpus/templates/content/app/i18n-data.ts +287 -0
  67. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  68. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  69. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  70. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  71. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  72. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  73. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  74. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  75. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  76. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  77. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  78. package/corpus/templates/content/parity/matrix.md +27 -25
  79. package/corpus/templates/content/parity/matrix.ts +50 -2
  80. package/corpus/templates/content/server/db/schema.ts +47 -0
  81. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  82. package/corpus/templates/content/server/plugins/db.ts +53 -0
  83. package/corpus/templates/content/shared/api.ts +70 -1
  84. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  85. package/corpus/templates/content/shared/nfm.ts +3 -3
  86. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  87. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  88. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  89. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  90. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  91. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  92. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  93. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  94. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  95. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  96. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  97. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  98. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  99. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  100. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  101. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  102. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  103. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  104. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  105. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  106. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  107. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  108. package/dist/client/review/ReviewCommentComposer.js +3 -3
  109. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  110. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  111. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  112. package/dist/client/review/ReviewThreadPanel.js +6 -4
  113. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  114. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  115. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  116. package/dist/client/settings/SettingsPanel.js +11 -4
  117. package/dist/client/settings/SettingsPanel.js.map +1 -1
  118. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  119. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  120. package/dist/client/settings/useBuilderStatus.js +19 -6
  121. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  122. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  123. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  124. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  125. package/dist/collab/awareness.d.ts +2 -2
  126. package/dist/collab/awareness.d.ts.map +1 -1
  127. package/dist/observability/routes.d.ts +5 -5
  128. package/dist/resources/handlers.d.ts +1 -1
  129. package/dist/secrets/routes.d.ts +9 -9
  130. package/dist/server/auth.d.ts +1 -0
  131. package/dist/server/auth.d.ts.map +1 -1
  132. package/dist/server/auth.js +19 -2
  133. package/dist/server/auth.js.map +1 -1
  134. package/dist/server/builder-browser.d.ts +78 -0
  135. package/dist/server/builder-browser.d.ts.map +1 -1
  136. package/dist/server/builder-browser.js +246 -1
  137. package/dist/server/builder-browser.js.map +1 -1
  138. package/dist/server/core-routes-plugin.d.ts +41 -0
  139. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  140. package/dist/server/core-routes-plugin.js +260 -14
  141. package/dist/server/core-routes-plugin.js.map +1 -1
  142. package/docs/content/deployment.mdx +9 -7
  143. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  144. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  145. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  146. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  147. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  148. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  149. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  150. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  151. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  152. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  153. package/package.json +2 -2
@@ -0,0 +1,99 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { assertAccess } from "@agent-native/core/sharing";
3
+ import { z } from "zod";
4
+
5
+ import type {
6
+ PreviewBuilderSourceReviewRequest,
7
+ PreviewBuilderSourceReviewResponse,
8
+ } from "../shared/api.js";
9
+ import {
10
+ getContentDatabaseSourceSnapshotForWrite,
11
+ resolveDatabaseForSourceMutation,
12
+ } from "./_database-source-utils.js";
13
+ import {
14
+ BUILDER_SOURCE_REVIEW_PREPARE_LIMIT,
15
+ buildBuilderSourceReviewPayload,
16
+ reviewPreparePriority,
17
+ withAuthoritativeBuilderTargetRows,
18
+ } from "./prepare-builder-source-review.js";
19
+
20
+ export default defineAction({
21
+ description:
22
+ "Preview complete Builder CMS review diffs, including document body changes, without approving or writing anything.",
23
+ schema: z.object({
24
+ databaseId: z.string().optional().describe("Database ID"),
25
+ documentId: z.string().optional().describe("Database document/page ID"),
26
+ sourceId: z.string().optional().describe("Specific Builder source ID"),
27
+ scope: z.enum(["selected", "all"]).optional().default("all"),
28
+ documentIds: z
29
+ .array(z.string())
30
+ .max(BUILDER_SOURCE_REVIEW_PREPARE_LIMIT)
31
+ .optional()
32
+ .describe("Optional selected document IDs that bound the preview"),
33
+ }),
34
+ http: { method: "GET" },
35
+ readOnly: true,
36
+ run: async (
37
+ args: PreviewBuilderSourceReviewRequest,
38
+ ): Promise<PreviewBuilderSourceReviewResponse> => {
39
+ const database = await resolveDatabaseForSourceMutation(args);
40
+ if (!database) throw new Error("Database not found.");
41
+ await assertAccess("document", database.documentId, "editor");
42
+
43
+ const source = await getContentDatabaseSourceSnapshotForWrite(
44
+ database,
45
+ args.sourceId,
46
+ );
47
+ if (!source || source.sourceType !== "builder-cms") {
48
+ throw new Error("Attach a Builder CMS source before reviewing updates.");
49
+ }
50
+
51
+ const selectedDocumentIds = new Set(args.documentIds ?? []);
52
+ if (args.scope === "selected" && selectedDocumentIds.size === 0) {
53
+ throw new Error("Select at least one Builder row before reviewing it.");
54
+ }
55
+ const allReviewableChanges = source.changeSets.filter((changeSet) => {
56
+ if (
57
+ changeSet.direction !== "outbound" ||
58
+ (changeSet.state !== "pending_push" &&
59
+ changeSet.state !== "staged_revision" &&
60
+ changeSet.state !== "approved")
61
+ ) {
62
+ return false;
63
+ }
64
+ return (
65
+ args.scope === "all" ||
66
+ (!!changeSet.documentId &&
67
+ selectedDocumentIds.has(changeSet.documentId))
68
+ );
69
+ });
70
+ const changeSets = [...allReviewableChanges]
71
+ .sort(
72
+ (left, right) =>
73
+ reviewPreparePriority(left) - reviewPreparePriority(right),
74
+ )
75
+ .slice(0, BUILDER_SOURCE_REVIEW_PREPARE_LIMIT);
76
+ const authoritativeSource = await withAuthoritativeBuilderTargetRows({
77
+ source,
78
+ changeSets,
79
+ });
80
+ const review =
81
+ changeSets.length > 0
82
+ ? buildBuilderSourceReviewPayload({
83
+ source: authoritativeSource,
84
+ changeSets,
85
+ })
86
+ : null;
87
+ if (review) {
88
+ review.totalRowCount = allReviewableChanges.length;
89
+ review.preparedRowLimit = changeSets.length;
90
+ }
91
+
92
+ return {
93
+ sourceId: source.id,
94
+ sourceTable: source.sourceTable,
95
+ changeSetIds: changeSets.map((changeSet) => changeSet.id),
96
+ review,
97
+ };
98
+ },
99
+ });
@@ -38,7 +38,7 @@ function executableWriteModes(
38
38
 
39
39
  export default defineAction({
40
40
  description:
41
- "Set the tiered Builder CMS write mode for one source. Writes stay off by default and can only be enabled for the safe Builder test collection.",
41
+ "Set the tiered Builder CMS write mode for one source. Writes stay off by default and require document administrator access to enable or change.",
42
42
  schema: z.object({
43
43
  databaseId: z.string().optional().describe("Database ID"),
44
44
  documentId: z.string().optional().describe("Database document/page ID"),
@@ -79,7 +79,7 @@ export default defineAction({
79
79
  ): Promise<ContentDatabaseResponse> => {
80
80
  const database = await resolveDatabaseForSourceMutation(args);
81
81
  if (!database) throw new Error("Database not found.");
82
- await assertAccess("document", database.documentId, "editor");
82
+ await assertAccess("document", database.documentId, "admin");
83
83
 
84
84
  const db = getDb();
85
85
  const source = await getExistingSourceForWrite(database.id, args.sourceId);
@@ -0,0 +1,153 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import {
3
+ getRequestOrgId,
4
+ getRequestUserEmail,
5
+ } from "@agent-native/core/server";
6
+ import { assertAccess } from "@agent-native/core/sharing";
7
+ import { and, eq, sql } from "drizzle-orm";
8
+ import { z } from "zod";
9
+
10
+ import { getDb, schema } from "../server/db/index.js";
11
+ import { readPreviewDocumentDraft } from "./_preview-document-draft.js";
12
+
13
+ const draftPayload = z.object({
14
+ title: z.string().max(10_000),
15
+ content: z.string().max(500_000),
16
+ baseDocumentUpdatedAt: z.string().max(100).nullable(),
17
+ loadedContentWasEmpty: z.boolean(),
18
+ deferredReason: z.enum(["hydration", "conflict"]).nullable(),
19
+ });
20
+
21
+ function draftId() {
22
+ return crypto.randomUUID();
23
+ }
24
+
25
+ export default defineAction({
26
+ description:
27
+ "Atomically save or delete the current user's private preview draft.",
28
+ schema: z.discriminatedUnion("operation", [
29
+ z.object({
30
+ operation: z.literal("upsert"),
31
+ documentId: z.string().min(1),
32
+ expectedVersion: z.number().int().positive().nullable(),
33
+ draft: draftPayload,
34
+ }),
35
+ z.object({
36
+ operation: z.literal("delete"),
37
+ documentId: z.string().min(1),
38
+ expectedVersion: z.number().int().positive(),
39
+ expectedTitle: z.string().max(10_000),
40
+ expectedContent: z.string().max(500_000),
41
+ }),
42
+ ]),
43
+ agentTool: false,
44
+ toolCallable: false,
45
+ run: async (args, ctx) => {
46
+ const userEmail = getRequestUserEmail();
47
+ const orgId = getRequestOrgId() ?? "";
48
+ if (!userEmail) throw new Error("Not authenticated.");
49
+ await assertAccess("document", args.documentId, "editor");
50
+ const db = getDb();
51
+ const ownerFilter = and(
52
+ eq(schema.documentPreviewDrafts.ownerEmail, userEmail),
53
+ eq(schema.documentPreviewDrafts.orgId, orgId),
54
+ eq(schema.documentPreviewDrafts.documentId, args.documentId),
55
+ );
56
+
57
+ if (args.operation === "delete") {
58
+ const deleted = await db
59
+ .delete(schema.documentPreviewDrafts)
60
+ .where(
61
+ and(
62
+ ownerFilter,
63
+ eq(schema.documentPreviewDrafts.version, args.expectedVersion),
64
+ eq(schema.documentPreviewDrafts.title, args.expectedTitle),
65
+ eq(schema.documentPreviewDrafts.content, args.expectedContent),
66
+ ),
67
+ )
68
+ .returning({ id: schema.documentPreviewDrafts.id });
69
+ if (deleted.length > 0)
70
+ return { status: "deleted" as const, draft: null };
71
+ return {
72
+ status: "conflict" as const,
73
+ draft: await readPreviewDocumentDraft(
74
+ userEmail,
75
+ orgId,
76
+ args.documentId,
77
+ ),
78
+ };
79
+ }
80
+
81
+ const now = new Date().toISOString();
82
+ if (args.expectedVersion === null) {
83
+ const inserted = await db
84
+ .insert(schema.documentPreviewDrafts)
85
+ .values({
86
+ id: draftId(),
87
+ ownerEmail: userEmail,
88
+ orgId,
89
+ documentId: args.documentId,
90
+ title: args.draft.title,
91
+ content: args.draft.content,
92
+ baseDocumentUpdatedAt: args.draft.baseDocumentUpdatedAt,
93
+ loadedContentWasEmpty: args.draft.loadedContentWasEmpty ? 1 : 0,
94
+ deferredReason: args.draft.deferredReason,
95
+ version: 1,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ })
99
+ .onConflictDoNothing({
100
+ target: [
101
+ schema.documentPreviewDrafts.ownerEmail,
102
+ schema.documentPreviewDrafts.orgId,
103
+ schema.documentPreviewDrafts.documentId,
104
+ ],
105
+ })
106
+ .returning({ version: schema.documentPreviewDrafts.version });
107
+ if (inserted.length > 0) {
108
+ return {
109
+ status: "saved" as const,
110
+ draft: await readPreviewDocumentDraft(
111
+ userEmail,
112
+ orgId,
113
+ args.documentId,
114
+ ),
115
+ };
116
+ }
117
+ } else {
118
+ const updated = await db
119
+ .update(schema.documentPreviewDrafts)
120
+ .set({
121
+ title: args.draft.title,
122
+ content: args.draft.content,
123
+ baseDocumentUpdatedAt: args.draft.baseDocumentUpdatedAt,
124
+ loadedContentWasEmpty: args.draft.loadedContentWasEmpty ? 1 : 0,
125
+ deferredReason: args.draft.deferredReason,
126
+ version: sql`${schema.documentPreviewDrafts.version} + 1`,
127
+ updatedAt: now,
128
+ })
129
+ .where(
130
+ and(
131
+ ownerFilter,
132
+ eq(schema.documentPreviewDrafts.version, args.expectedVersion),
133
+ ),
134
+ )
135
+ .returning({ version: schema.documentPreviewDrafts.version });
136
+ if (updated.length > 0) {
137
+ return {
138
+ status: "saved" as const,
139
+ draft: await readPreviewDocumentDraft(
140
+ userEmail,
141
+ orgId,
142
+ args.documentId,
143
+ ),
144
+ };
145
+ }
146
+ }
147
+
148
+ return {
149
+ status: "conflict" as const,
150
+ draft: await readPreviewDocumentDraft(userEmail, orgId, args.documentId),
151
+ };
152
+ },
153
+ });
@@ -489,6 +489,24 @@ export function filesMediaEditorValue(value: DocumentProperty["value"]) {
489
489
  return filesMediaItems(value).join("\n");
490
490
  }
491
491
 
492
+ export function isValidFilesMediaLink(value: string) {
493
+ try {
494
+ const url = new URL(value.trim());
495
+ return url.protocol === "http:" || url.protocol === "https:";
496
+ } catch {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ export function mergeFilesMediaItems(items: string[], pendingLink: string) {
502
+ const trimmed = pendingLink.trim();
503
+ if (!trimmed || !isValidFilesMediaLink(trimmed)) return items;
504
+ if (items.some((item) => item.toLowerCase() === trimmed.toLowerCase())) {
505
+ return items;
506
+ }
507
+ return [...items, trimmed];
508
+ }
509
+
492
510
  export function filesMediaLabel(value: string) {
493
511
  const trimmed = value.trim();
494
512
  if (!trimmed) return "File";
@@ -2060,16 +2078,8 @@ function FilesMediaValueEditor({
2060
2078
  }, []);
2061
2079
 
2062
2080
  function addItem(value: string) {
2063
- const trimmed = value.trim();
2064
- if (!trimmed) return;
2065
- setItems((current) => {
2066
- if (
2067
- current.some((item) => item.toLowerCase() === trimmed.toLowerCase())
2068
- ) {
2069
- return current;
2070
- }
2071
- return [...current, trimmed];
2072
- });
2081
+ if (!isValidFilesMediaLink(value)) return;
2082
+ setItems((current) => mergeFilesMediaItems(current, value));
2073
2083
  setLinkValue("");
2074
2084
  }
2075
2085
 
@@ -2126,11 +2136,11 @@ function FilesMediaValueEditor({
2126
2136
  className="grid gap-3"
2127
2137
  onSubmit={(event) => {
2128
2138
  event.preventDefault();
2129
- if (linkValue.trim()) {
2130
- addItem(linkValue);
2139
+ if (linkValue.trim() && !isValidFilesMediaLink(linkValue)) {
2140
+ linkInputRef.current?.reportValidity();
2131
2141
  return;
2132
2142
  }
2133
- void save();
2143
+ void save(mergeFilesMediaItems(items, linkValue));
2134
2144
  }}
2135
2145
  >
2136
2146
  <div className="grid max-h-48 gap-1 overflow-auto">
@@ -2174,9 +2184,11 @@ function FilesMediaValueEditor({
2174
2184
  <div className="flex gap-1">
2175
2185
  <Input
2176
2186
  ref={linkInputRef}
2177
- aria-label={t("editor.properties.addPropertyLink", {
2187
+ aria-label={t("editor.properties.editValue", {
2178
2188
  name: property.definition.name,
2179
2189
  })}
2190
+ type="url"
2191
+ pattern="[hH][tT][tT][pP][sS]?://.*"
2180
2192
  value={linkValue}
2181
2193
  placeholder={t("editor.properties.pasteFileOrMediaLink")}
2182
2194
  onChange={(event) => setLinkValue(event.target.value)}
@@ -2193,7 +2205,7 @@ function FilesMediaValueEditor({
2193
2205
  size="sm"
2194
2206
  className="shrink-0"
2195
2207
  onClick={() => addItem(linkValue)}
2196
- disabled={!linkValue.trim() || mutation.isPending}
2208
+ disabled={!isValidFilesMediaLink(linkValue) || mutation.isPending}
2197
2209
  >
2198
2210
  <IconPlus className="size-3.5" />
2199
2211
  {t("editor.properties.add")}
@@ -924,6 +924,28 @@ export function shouldPersistLocalFileEditorUpdate({
924
924
  return Boolean(transactionUiEvent);
925
925
  }
926
926
 
927
+ function isEffectivelyEmptyEditorContent(value: string): boolean {
928
+ const normalized = value.trim();
929
+ return normalized === "" || normalized === "<empty-block/>";
930
+ }
931
+
932
+ export function shouldPersistEffectivelyEmptyEditorUpdate({
933
+ nextContent,
934
+ userInitiated,
935
+ }: {
936
+ nextContent: string;
937
+ userInitiated: boolean;
938
+ }): boolean {
939
+ if (!isEffectivelyEmptyEditorContent(nextContent)) return true;
940
+ // Empty editor state is never worth persisting without a user gesture. This
941
+ // also covers the preview remount window where `content` can briefly be an
942
+ // empty list snapshot even though its retained save controller still has a
943
+ // rich confirmed baseline. Comparing only to this render's prop would let
944
+ // that mount-time filler mark the retained controller dirty and its
945
+ // flush-on-release path would then overwrite SQL.
946
+ return userInitiated;
947
+ }
948
+
927
949
  function isActiveSlashCommandDraft(editor: CoreEditor): boolean {
928
950
  const { state } = editor;
929
951
  if (!state.selection.empty) return false;
@@ -1086,7 +1108,9 @@ function getVisualEditorPlaceholder({
1086
1108
  if (level === 1) return "Heading 1";
1087
1109
  if (level === 2) return "Heading 2";
1088
1110
  if (level === 3) return "Heading 3";
1089
- return "Heading 4";
1111
+ if (level === 4) return "Heading 4";
1112
+ if (level === 5) return "Heading 5";
1113
+ return "Heading 6";
1090
1114
  }
1091
1115
 
1092
1116
  if (
@@ -1300,6 +1324,7 @@ export function createVisualEditorExtensions({
1300
1324
  starterKit: {
1301
1325
  blockquote: false,
1302
1326
  paragraph: false,
1327
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
1303
1328
  horizontalRule: {},
1304
1329
  dropcursor: { color: false, width: 3, class: "notion-dropcursor" },
1305
1330
  },
@@ -1742,7 +1767,11 @@ export function VisualEditor({
1742
1767
  const persistEditorContent = useCallback(
1743
1768
  (
1744
1769
  editorToPersist: CoreEditor,
1745
- options?: { markdown?: string; immediate?: boolean },
1770
+ options?: {
1771
+ markdown?: string;
1772
+ immediate?: boolean;
1773
+ userInitiated?: boolean;
1774
+ },
1746
1775
  ) => {
1747
1776
  const guards = guardsRef.current;
1748
1777
  if (!guards) return false;
@@ -1750,6 +1779,20 @@ export function VisualEditor({
1750
1779
  const normalized =
1751
1780
  options?.markdown ?? docToNfm(editorToPersist.getJSON() as any);
1752
1781
  if (localFileMode && normalized === content) return true;
1782
+ // TipTap/Yjs can emit a local-looking empty-paragraph transaction while
1783
+ // an editor is mounting or reconciling. Content serializes that filler
1784
+ // as `<empty-block/>`, so the generic whitespace-only collab guard does
1785
+ // not catch it. Never let that lifecycle normalization clear a saved
1786
+ // body. A real Select All/Delete (or Cut) records user intent through
1787
+ // the DOM handlers below and is still allowed to persist normally.
1788
+ if (
1789
+ !shouldPersistEffectivelyEmptyEditorUpdate({
1790
+ nextContent: normalized,
1791
+ userInitiated: options?.userInitiated === true,
1792
+ })
1793
+ ) {
1794
+ return true;
1795
+ }
1753
1796
  if (options?.immediate && onSaveContentRef.current) {
1754
1797
  return onSaveContentRef.current(normalized);
1755
1798
  }
@@ -1923,7 +1966,12 @@ export function VisualEditor({
1923
1966
  return;
1924
1967
  }
1925
1968
  if (isActiveSlashCommandDraft(editor)) return;
1926
- persistEditorContent(editor);
1969
+ persistEditorContent(editor, {
1970
+ userInitiated:
1971
+ transaction.getMeta(LOCAL_FILE_USER_EDIT_META) === true ||
1972
+ Date.now() - lastUserEditIntentAtRef.current < 2000 ||
1973
+ Boolean(transaction.getMeta("uiEvent")),
1974
+ });
1927
1975
  },
1928
1976
  });
1929
1977
 
@@ -2239,10 +2287,14 @@ export function VisualEditor({
2239
2287
  documentId={documentId}
2240
2288
  notionPageId={notionPageId}
2241
2289
  onDraftCommitted={() => {
2242
- void persistEditorContent(editor);
2290
+ void persistEditorContent(editor, { userInitiated: true });
2243
2291
  }}
2244
2292
  onDraftPersisted={(markdown) =>
2245
- persistEditorContent(editor, { markdown, immediate: true })
2293
+ persistEditorContent(editor, {
2294
+ markdown,
2295
+ immediate: true,
2296
+ userInitiated: true,
2297
+ })
2246
2298
  }
2247
2299
  />
2248
2300
  ) : null}
@@ -89,6 +89,33 @@ export function previewBodyHydrationIsPending(args: {
89
89
  );
90
90
  }
91
91
 
92
+ /**
93
+ * A draft that began from an empty source snapshot must not automatically
94
+ * replace a non-empty Builder body that arrived while hydration was running.
95
+ * Keep the draft dirty/recoverable and let the user decide which body to keep.
96
+ */
97
+ export function previewDraftConflictsWithHydratedBody(args: {
98
+ loadedContent: string | null | undefined;
99
+ loadedUpdatedAt: string | null | undefined;
100
+ loadedContentWasEmpty: boolean | undefined;
101
+ pendingContent: string | null | undefined;
102
+ hydratedContent: string | null | undefined;
103
+ hydratedUpdatedAt: string | null | undefined;
104
+ }) {
105
+ const sourceChangedSinceDraftLoaded =
106
+ (!!args.loadedUpdatedAt &&
107
+ !!args.hydratedUpdatedAt &&
108
+ args.loadedUpdatedAt !== args.hydratedUpdatedAt) ||
109
+ args.loadedContent !== args.hydratedContent;
110
+ return (
111
+ !isEffectivelyEmptyDocumentContent(args.pendingContent) &&
112
+ args.pendingContent !== args.hydratedContent &&
113
+ sourceChangedSinceDraftLoaded &&
114
+ (args.loadedContentWasEmpty === true ||
115
+ args.loadedContent !== args.hydratedContent)
116
+ );
117
+ }
118
+
92
119
  export function previewBodyHydrationIsTerminalError(args: {
93
120
  item: Pick<ContentDatabaseItem, "bodyHydration" | "document">;
94
121
  document: Pick<Document, "databaseMembership"> | null | undefined;