@agent-native/core 0.182.1-nightly-20260917183518 → 0.182.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +6 -0
  3. package/corpus/templates/calendar/CHANGELOG.md +1 -0
  4. package/corpus/templates/clips/CHANGELOG.md +7 -0
  5. package/corpus/templates/clips/app/components/library/sort-menu.tsx +2 -2
  6. package/corpus/templates/clips/changelog/2026-09-17-clips-waits-for-authentication-before-showing-the-recording-.md +6 -0
  7. package/corpus/templates/clips/changelog/2026-09-17-the-clips-sort-control-keeps-its-keyboard-focus-ring-visible.md +6 -0
  8. package/corpus/templates/clips/chrome-extension/src/popup.html +1 -1
  9. package/corpus/templates/clips/chrome-extension/src/popup.ts +30 -21
  10. package/corpus/templates/content/CHANGELOG.md +7 -0
  11. package/corpus/templates/content/actions/resolve-preview-document-draft.ts +181 -16
  12. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +28 -15
  13. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +11 -0
  14. package/corpus/templates/content/app/components/editor/document-save-rebase.ts +13 -10
  15. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +80 -0
  16. package/corpus/templates/content/changelog/2026-09-16-recovery-choices-stay-attached-to-the-draft-you-reviewed.md +6 -0
  17. package/corpus/templates/design/CHANGELOG.md +29 -0
  18. package/corpus/templates/design/actions/import-design-source.ts +42 -2
  19. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +26 -15
  20. package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +83 -1
  21. package/corpus/templates/design/app/i18n-data.ts +3 -1
  22. package/corpus/templates/design/app/lib/design-file-upload.ts +6 -4
  23. package/corpus/templates/design/app/lib/design-import.ts +2 -0
  24. package/corpus/templates/design/app/lib/fig-client-import.ts +207 -49
  25. package/corpus/templates/design/changelog/2026-09-17-large-fig-files-import-directly-in-the-browser-without-the-o.md +6 -0
  26. package/corpus/templates/design/server/lib/fig-file-decoder.ts +31 -8
  27. package/corpus/templates/design/server/lib/import-design-files.ts +188 -75
  28. package/corpus/templates/design/server/plugins/db.ts +16 -7
  29. package/corpus/templates/design/shared/fig-to-frames.ts +55 -25
  30. package/corpus/templates/factory/CHANGELOG.md +6 -0
  31. package/corpus/templates/mail/CHANGELOG.md +6 -0
  32. package/corpus/templates/slides/CHANGELOG.md +12 -0
  33. package/corpus/templates/slides/app/components/editor/AssetLibraryPanel.tsx +1 -1
  34. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +1 -2
  35. package/corpus/templates/slides/app/hooks/use-agent-generating.ts +82 -2
  36. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +9 -2
  37. package/corpus/templates/slides/app/lib/slide-image-replacement.ts +1 -1
  38. package/corpus/templates/slides/app/pages/DeckEditor.tsx +1 -1
  39. package/corpus/templates/slides/server/handlers/assets.ts +134 -8
  40. package/corpus/templates/slides/server/handlers/uploads.ts +23 -10
  41. package/corpus/templates/slides/shared/upload-types.ts +1 -0
  42. package/dist/application-state/store.d.ts +9 -0
  43. package/dist/application-state/store.js +18 -0
  44. package/dist/automation/index.d.ts +1 -1
  45. package/dist/collab/struct-routes.d.ts +1 -1
  46. package/dist/deploy/build.js +7 -0
  47. package/dist/file-upload/actions/upload-image.d.ts +41 -10
  48. package/dist/file-upload/actions/upload-image.js +399 -19
  49. package/dist/jobs/scheduler.js +11 -0
  50. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  51. package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
  52. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  53. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  54. package/dist/resources/handlers.d.ts +1 -1
  55. package/dist/secrets/routes.d.ts +3 -3
  56. package/package.json +4 -4
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 9514
34
+ - template files: 9518
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Agent-Native Analytics are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-16
7
+
8
+ ### Improved
9
+
10
+ - Dashboards open without background synchronization scans during cold starts
11
+
6
12
  ## 2026-09-15
7
13
 
8
14
  ### Improved
@@ -7,6 +7,7 @@ time from the command menu (Cmd+K → "What's new") or from Settings.
7
7
 
8
8
  ### Fixed
9
9
 
10
+ - Booking links reject unsafe validation rules without freezing the booking page
10
11
  - Booking link time-zone arrows now scroll to earlier and later times.
11
12
 
12
13
  ## 2026-09-15
@@ -3,6 +3,13 @@
3
3
  All notable user-facing changes to Clips are documented here. Open it any time
4
4
  from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-17
7
+
8
+ ### Fixed
9
+
10
+ - Clips waits for authentication before showing the recording action
11
+ - The Clips sort control keeps its keyboard focus ring visible
12
+
6
13
  ## 2026-09-16
7
14
 
8
15
  ### Improved
@@ -33,9 +33,9 @@ export function SortMenu({ value, onChange }: SortMenuProps) {
33
33
  <TooltipTrigger asChild>
34
34
  <DropdownMenuTrigger asChild>
35
35
  <Button
36
- variant="ghost"
36
+ variant="ghost-inset"
37
37
  size="icon"
38
- className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent/40 hover:text-foreground"
38
+ className="h-8 w-8 shrink-0"
39
39
  aria-label={LABELS[value]}
40
40
  >
41
41
  <IconArrowsSort className="h-3.5 w-3.5" />
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-17
4
+ ---
5
+
6
+ Clips waits for authentication before showing the recording action
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-17
4
+ ---
5
+
6
+ The Clips sort control keeps its keyboard focus ring visible
@@ -388,7 +388,7 @@
388
388
  </button>
389
389
  </div>
390
390
 
391
- <button id="start" class="primary start" type="button">
391
+ <button id="start" class="primary start" type="button" hidden>
392
392
  Start recording
393
393
  </button>
394
394
  <button id="sign-in" class="primary start" type="button" hidden>
@@ -75,6 +75,17 @@ type PopupStatusResponse = {
75
75
 
76
76
  type AuthStatus = "checking" | "signed-in" | "signed-out";
77
77
 
78
+ export function recordingControlVisibility(
79
+ recording: NativeRecording | null,
80
+ authStatus: AuthStatus,
81
+ ): { startHidden: boolean; signInHidden: boolean } {
82
+ const active = Boolean(recording);
83
+ return {
84
+ startHidden: active || authStatus !== "signed-in",
85
+ signInHidden: active || authStatus !== "signed-out",
86
+ };
87
+ }
88
+
78
89
  type StoredAuth = {
79
90
  token: string;
80
91
  email?: string;
@@ -769,6 +780,7 @@ function formatDuration(startedAtMs: number): string {
769
780
  function renderActiveRecording(
770
781
  recording: NativeRecording | null,
771
782
  arming = false,
783
+ authStatus: AuthStatus = "checking",
772
784
  ): void {
773
785
  const idleContent = byId<HTMLDivElement>("idle-content");
774
786
  const activeContent = byId<HTMLDivElement>("active-content");
@@ -783,11 +795,12 @@ function renderActiveRecording(
783
795
  document.querySelector<HTMLDivElement>(".recording-actions");
784
796
 
785
797
  const active = Boolean(recording);
798
+ const controlVisibility = recordingControlVisibility(recording, authStatus);
786
799
  idleContent.hidden = active;
787
800
  activeContent.hidden = !active;
788
- start.hidden = active;
801
+ start.hidden = controlVisibility.startHidden;
789
802
  start.disabled = arming;
790
- signIn.hidden = true;
803
+ signIn.hidden = controlVisibility.signInHidden;
791
804
  if (recordingActions) recordingActions.hidden = !active;
792
805
  if (!recording) {
793
806
  setStorageHelp(false);
@@ -991,7 +1004,7 @@ async function init(): Promise<void> {
991
1004
  await sendSimpleMessage<PopupStatusResponse>("CLIPS_POPUP_STATUS");
992
1005
  activeRecording = status.activeRecording ?? null;
993
1006
  arming = Boolean(status.arming);
994
- renderActiveRecording(activeRecording, arming);
1007
+ renderActiveRecording(activeRecording, arming, authStatus);
995
1008
  };
996
1009
  await refreshActiveRecording();
997
1010
  if (activeRecording || arming) {
@@ -1222,11 +1235,8 @@ async function init(): Promise<void> {
1222
1235
  });
1223
1236
 
1224
1237
  authStatus = await readAuthStatus(settings);
1225
- if (!activeRecording && authStatus === "signed-out") {
1226
- start.hidden = true;
1227
- signIn.hidden = false;
1228
- setStatus("");
1229
- }
1238
+ renderActiveRecording(activeRecording, arming, authStatus);
1239
+ if (authStatus === "signed-out") setStatus("");
1230
1240
 
1231
1241
  start.addEventListener("click", async () => {
1232
1242
  start.disabled = true;
@@ -1236,9 +1246,7 @@ async function init(): Promise<void> {
1236
1246
  try {
1237
1247
  authStatus = await readAuthStatus(settings);
1238
1248
  if (authStatus === "signed-out") {
1239
- start.disabled = false;
1240
- start.hidden = true;
1241
- signIn.hidden = false;
1249
+ renderActiveRecording(activeRecording, arming, authStatus);
1242
1250
  setStatus("");
1243
1251
  return;
1244
1252
  }
@@ -1375,8 +1383,7 @@ async function init(): Promise<void> {
1375
1383
  await sendSimpleMessage<PopupStartResponse>("CLIPS_POPUP_CANCEL");
1376
1384
  if (response.ok) {
1377
1385
  activeRecording = null;
1378
- renderActiveRecording(null);
1379
- if (authStatus === "signed-in") start.hidden = false;
1386
+ renderActiveRecording(null, false, authStatus);
1380
1387
  setStatus("");
1381
1388
  stop.disabled = false;
1382
1389
  discard.disabled = false;
@@ -1398,12 +1405,14 @@ async function init(): Promise<void> {
1398
1405
  });
1399
1406
  }
1400
1407
 
1401
- void init().catch((err) => {
1402
- captureExtensionError(err, {
1403
- tags: { surface: "popup", action: "init" },
1408
+ if (typeof document !== "undefined") {
1409
+ void init().catch((err) => {
1410
+ captureExtensionError(err, {
1411
+ tags: { surface: "popup", action: "init" },
1412
+ });
1413
+ setStatus(
1414
+ err instanceof Error ? err.message : "Could not load popup.",
1415
+ "error",
1416
+ );
1404
1417
  });
1405
- setStatus(
1406
- err instanceof Error ? err.message : "Could not load popup.",
1407
- "error",
1408
- );
1409
- });
1418
+ }
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Agent-Native Content are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new").
5
5
 
6
+ ## 2026-09-16
7
+
8
+ ### Fixed
9
+
10
+ - Content now combines non-overlapping edits from another tab or agent automatically, while recovery choices stay attached to the exact draft and saved page version you reviewed.
11
+
6
12
  ## 2026-09-15
7
13
 
8
14
  ### Fixed
@@ -27,6 +33,7 @@ time from the command menu (Cmd+K → "What's new").
27
33
 
28
34
  ### Fixed
29
35
 
36
+ - Tables now export as real tables. Downloading a page as PDF or HTML keeps rows, columns, header rows, header columns, and cell line breaks instead of flattening the table into plain text. Callouts, toggles, and column layouts survive the same export path, and printed tables repeat their header row across page breaks.
30
37
  - Suggestion previews and highlights stay aligned when editing text near lists.
31
38
 
32
39
  ## 2026-09-12
@@ -35,6 +35,8 @@ const durableClaimPayload = z.object({
35
35
  updatedAt: z.string().min(1),
36
36
  });
37
37
 
38
+ const PROCESSING_LEASE_MS = 5 * 60_000;
39
+
38
40
  function conflict(message: string, details?: Record<string, unknown>): never {
39
41
  throw new ActionContractError(message, {
40
42
  errorCode: "PREVIEW_DRAFT_RECOVERY_CONFLICT",
@@ -48,8 +50,10 @@ async function recoveryDocumentId(args: {
48
50
  expectedDraftVersion: number;
49
51
  expectedDraftTitle: string;
50
52
  expectedDraftContent: string;
53
+ expectedDocumentUpdatedAt: string;
51
54
  ownerEmail: string;
52
55
  orgId: string;
56
+ includeDocumentVersion: boolean;
53
57
  }): Promise<string> {
54
58
  const input = JSON.stringify([
55
59
  args.ownerEmail,
@@ -58,6 +62,7 @@ async function recoveryDocumentId(args: {
58
62
  args.expectedDraftVersion,
59
63
  args.expectedDraftTitle,
60
64
  args.expectedDraftContent,
65
+ ...(args.includeDocumentVersion ? [args.expectedDocumentUpdatedAt] : []),
61
66
  ]);
62
67
  const digest = await crypto.subtle.digest(
63
68
  "SHA-256",
@@ -104,14 +109,88 @@ export default defineAction({
104
109
  const access = await assertAccess("document", args.documentId, "editor");
105
110
  const ownerEmail = access.resource.ownerEmail as string;
106
111
  const db = getDb();
107
- const recoveryId = await recoveryDocumentId({
112
+ let recoveryId = await recoveryDocumentId({
113
+ ...args,
114
+ ownerEmail: userEmail,
115
+ orgId,
116
+ includeDocumentVersion: true,
117
+ });
118
+ let claimId = `draft-claim-${recoveryId.slice("recovery-".length)}`;
119
+ let claimDocumentId = ownerEmail === userEmail ? args.documentId : claimId;
120
+ const legacyRecoveryId = await recoveryDocumentId({
108
121
  ...args,
109
122
  ownerEmail: userEmail,
110
123
  orgId,
124
+ includeDocumentVersion: false,
111
125
  });
112
- const claimId = `draft-claim-${recoveryId.slice("recovery-".length)}`;
113
- const claimDocumentId =
114
- ownerEmail === userEmail ? args.documentId : claimId;
126
+ const legacyClaimId = `draft-claim-${legacyRecoveryId.slice(
127
+ "recovery-".length,
128
+ )}`;
129
+ const [currentClaim] = await db
130
+ .select({ id: schema.documentVersions.id })
131
+ .from(schema.documentVersions)
132
+ .where(
133
+ and(
134
+ eq(schema.documentVersions.id, claimId),
135
+ eq(schema.documentVersions.ownerEmail, userEmail),
136
+ eq(schema.documentVersions.documentId, claimDocumentId),
137
+ ),
138
+ )
139
+ .limit(1);
140
+ if (legacyClaimId !== claimId && !currentClaim) {
141
+ const [legacyClaim] = await db
142
+ .select({
143
+ id: schema.documentVersions.id,
144
+ chatContext: schema.documentVersions.chatContext,
145
+ })
146
+ .from(schema.documentVersions)
147
+ .where(
148
+ and(
149
+ eq(schema.documentVersions.id, legacyClaimId),
150
+ eq(schema.documentVersions.ownerEmail, userEmail),
151
+ eq(
152
+ schema.documentVersions.documentId,
153
+ ownerEmail === userEmail ? args.documentId : legacyClaimId,
154
+ ),
155
+ ),
156
+ )
157
+ .limit(1);
158
+ const [matchingDraft] = await db
159
+ .select({ id: schema.documentPreviewDrafts.id })
160
+ .from(schema.documentPreviewDrafts)
161
+ .where(
162
+ and(
163
+ eq(schema.documentPreviewDrafts.ownerEmail, userEmail),
164
+ eq(schema.documentPreviewDrafts.orgId, orgId),
165
+ eq(schema.documentPreviewDrafts.documentId, args.documentId),
166
+ eq(schema.documentPreviewDrafts.version, args.expectedDraftVersion),
167
+ eq(schema.documentPreviewDrafts.title, args.expectedDraftTitle),
168
+ eq(schema.documentPreviewDrafts.content, args.expectedDraftContent),
169
+ ),
170
+ )
171
+ .limit(1);
172
+ let legacyPayload: ReturnType<
173
+ typeof durableClaimPayload.safeParse
174
+ > | null = null;
175
+ if (legacyClaim?.chatContext) {
176
+ try {
177
+ legacyPayload = durableClaimPayload.safeParse(
178
+ JSON.parse(legacyClaim.chatContext),
179
+ );
180
+ } catch {
181
+ legacyPayload = null;
182
+ }
183
+ }
184
+ if (
185
+ legacyClaim &&
186
+ legacyPayload?.success &&
187
+ (!matchingDraft || legacyPayload.data.draftId === matchingDraft.id)
188
+ ) {
189
+ recoveryId = legacyRecoveryId;
190
+ claimId = legacyClaimId;
191
+ claimDocumentId = ownerEmail === userEmail ? args.documentId : claimId;
192
+ }
193
+ }
115
194
  const processingToken = crypto.randomUUID();
116
195
  const draftFilter = and(
117
196
  eq(schema.documentPreviewDrafts.ownerEmail, userEmail),
@@ -272,6 +351,13 @@ export default defineAction({
272
351
  if (payload.choice !== args.choice)
273
352
  conflict("This draft was already resolved with another choice.");
274
353
  if (payload.status === "resolved") {
354
+ if (
355
+ payload.draftId !== draft.id ||
356
+ payload.expectedDocumentUpdatedAt !==
357
+ args.expectedDocumentUpdatedAt
358
+ ) {
359
+ conflict("The saved draft changed during recovery.");
360
+ }
275
361
  const deleted = await tx
276
362
  .delete(schema.documentPreviewDrafts)
277
363
  .where(draftFilter)
@@ -280,10 +366,13 @@ export default defineAction({
280
366
  conflict("The saved draft changed during recovery.");
281
367
  return { status: "resolved" as const };
282
368
  }
369
+ if (payload.draftId !== draft.id)
370
+ conflict("The saved draft changed during recovery.");
283
371
  if (
284
372
  payload.status === "processing" &&
285
- payload.processingStartedAt &&
286
- Date.now() - Date.parse(payload.processingStartedAt) < 30_000
373
+ (!payload.processingStartedAt ||
374
+ Date.now() - Date.parse(payload.processingStartedAt) <
375
+ PROCESSING_LEASE_MS)
287
376
  )
288
377
  conflict("This recovery choice is already being applied.");
289
378
  await tx
@@ -350,15 +439,64 @@ export default defineAction({
350
439
  ) {
351
440
  conflict("The page changed after this recovery choice was reviewed.");
352
441
  }
353
- if (
354
- payload.status === "processing" &&
355
- payload.processingStartedAt &&
356
- Date.now() - Date.parse(payload.processingStartedAt) < 30_000
357
- ) {
358
- conflict("This recovery choice is already being applied.");
442
+ if (payload.status === "processing") {
443
+ const alreadyApplied =
444
+ args.choice === "use_saved" ||
445
+ (args.choice === "keep_mine" &&
446
+ currentDocument.title === claim.title &&
447
+ currentDocument.content === claim.content) ||
448
+ (args.choice === "save_separately" &&
449
+ (
450
+ await tx
451
+ .select({
452
+ ownerEmail: schema.documents.ownerEmail,
453
+ title: schema.documents.title,
454
+ content: schema.documents.content,
455
+ })
456
+ .from(schema.documents)
457
+ .where(
458
+ and(
459
+ eq(schema.documents.id, recoveryId),
460
+ eq(schema.documents.ownerEmail, userEmail),
461
+ eq(schema.documents.title, claim.title),
462
+ eq(schema.documents.content, claim.content),
463
+ ),
464
+ )
465
+ .limit(1)
466
+ ).length === 1);
467
+ if (!alreadyApplied) {
468
+ if (
469
+ !payload.processingStartedAt ||
470
+ Date.now() - Date.parse(payload.processingStartedAt) <
471
+ PROCESSING_LEASE_MS
472
+ ) {
473
+ conflict("This recovery choice is already being applied.");
474
+ }
475
+ } else {
476
+ const resolvedAt = new Date().toISOString();
477
+ const resolved = await tx
478
+ .update(schema.documentVersions)
479
+ .set({
480
+ chatContext: JSON.stringify({
481
+ ...payload,
482
+ status: "resolved",
483
+ }),
484
+ updatedAt: resolvedAt,
485
+ })
486
+ .where(
487
+ and(
488
+ eq(schema.documentVersions.id, claimId),
489
+ eq(schema.documentVersions.chatContext, claim.chatContext),
490
+ ),
491
+ )
492
+ .returning({ id: schema.documentVersions.id });
493
+ if (resolved.length !== 1)
494
+ conflict("This recovery choice is already being applied.");
495
+ return false;
496
+ }
359
497
  }
360
498
  const now = new Date().toISOString();
361
- await tx
499
+ const acquired = await tx
362
500
  .update(schema.documentVersions)
363
501
  .set({
364
502
  chatContext: JSON.stringify({
@@ -369,7 +507,15 @@ export default defineAction({
369
507
  }),
370
508
  updatedAt: now,
371
509
  })
372
- .where(eq(schema.documentVersions.id, claimId));
510
+ .where(
511
+ and(
512
+ eq(schema.documentVersions.id, claimId),
513
+ eq(schema.documentVersions.chatContext, claim.chatContext),
514
+ ),
515
+ )
516
+ .returning({ id: schema.documentVersions.id });
517
+ if (acquired.length !== 1)
518
+ conflict("This recovery choice is already being applied.");
373
519
  return true;
374
520
  });
375
521
  const markClaimResolved = async () => {
@@ -395,7 +541,7 @@ export default defineAction({
395
541
  if (payload.status === "resolved") return;
396
542
  if (payload.processingToken !== processingToken)
397
543
  conflict("This recovery choice is already being applied.");
398
- await tx
544
+ const resolved = await tx
399
545
  .update(schema.documentVersions)
400
546
  .set({
401
547
  chatContext: JSON.stringify({ ...payload, status: "resolved" }),
@@ -406,8 +552,11 @@ export default defineAction({
406
552
  eq(schema.documentVersions.id, claimId),
407
553
  eq(schema.documentVersions.ownerEmail, userEmail),
408
554
  eq(schema.documentVersions.documentId, claimDocumentId),
555
+ eq(schema.documentVersions.chatContext, claim.chatContext),
409
556
  ),
410
- );
557
+ )
558
+ .returning({ id: schema.documentVersions.id });
559
+ if (resolved.length !== 1) conflict("The recovery claim was lost.");
411
560
  });
412
561
  };
413
562
  const restoreClaimedDraft = async (
@@ -511,6 +660,22 @@ export default defineAction({
511
660
  ctx,
512
661
  );
513
662
  if ((saved as DocumentUpdateConflictResponse).conflict === true) {
663
+ const [winner] = await db
664
+ .select()
665
+ .from(schema.documents)
666
+ .where(eq(schema.documents.id, args.documentId))
667
+ .limit(1);
668
+ if (
669
+ winner?.title === draft.title &&
670
+ winner.content === draft.content
671
+ ) {
672
+ await markClaimResolved();
673
+ return {
674
+ status: "resolved" as const,
675
+ choice: args.choice,
676
+ document: winner,
677
+ };
678
+ }
514
679
  await restoreClaimedDraft(draft);
515
680
  return {
516
681
  status: "document_conflict" as const,
@@ -1855,6 +1855,7 @@ function PageEditorSessionBody({
1855
1855
  const {
1856
1856
  report: reportReconcile,
1857
1857
  resolve: resolveReconcile,
1858
+ resolveAutomatically: resolveReconcileAutomatically,
1858
1859
  resolveChoice: resolveReconcileChoice,
1859
1860
  updateDraft: updateReconcileDraft,
1860
1861
  reportRetentionFailure,
@@ -2419,7 +2420,9 @@ function PageEditorSessionBody({
2419
2420
  ...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
2420
2421
  ...(baseRevision !== undefined ? { baseRevision } : {}),
2421
2422
  ...(updates.title !== undefined
2422
- ? { baseTitle: lastSavedTitleRef.current.title }
2423
+ ? {
2424
+ baseTitle: options.titleBase ?? lastSavedTitleRef.current.title,
2425
+ }
2423
2426
  : {}),
2424
2427
  });
2425
2428
  } catch (error) {
@@ -3941,26 +3944,36 @@ function PageEditorSessionBody({
3941
3944
  }
3942
3945
  localContentRef.current = result.content;
3943
3946
  setLocalContent(result.content);
3944
- reportReconcile(
3945
- result.status === "conflict" ? "conflict" : "failed",
3946
- result.content,
3947
- );
3948
- retainActiveRecoveryDraft({
3949
- localTitle: localTitleRef.current,
3950
- localDraft: result.content,
3951
- });
3952
3947
  if (result.status === "merged") {
3953
3948
  if (documentContentRef.current === result.serverContent) {
3954
- void resolveReconcile({
3955
- title: document.title,
3956
- content: result.serverContent,
3957
- updatedAt: documentUpdatedAtRef.current,
3958
- revision: result.serverRevision,
3949
+ void resolveReconcileAutomatically(
3950
+ {
3951
+ localTitle: localTitleRef.current,
3952
+ localDraft: result.content,
3953
+ },
3954
+ {
3955
+ title: documentTitleRef.current,
3956
+ content: result.serverContent,
3957
+ updatedAt: documentUpdatedAtRef.current,
3958
+ revision: result.serverRevision,
3959
+ },
3960
+ );
3961
+ } else {
3962
+ reportReconcile("failed", result.content);
3963
+ retainActiveRecoveryDraft({
3964
+ localTitle: localTitleRef.current,
3965
+ localDraft: result.content,
3959
3966
  });
3960
3967
  }
3968
+ return;
3961
3969
  }
3970
+ reportReconcile(result.status, result.content);
3971
+ retainActiveRecoveryDraft({
3972
+ localTitle: localTitleRef.current,
3973
+ localDraft: result.content,
3974
+ });
3962
3975
  },
3963
- [reportReconcile, resolveReconcile, retainActiveRecoveryDraft],
3976
+ [reportReconcile, resolveReconcileAutomatically, retainActiveRecoveryDraft],
3964
3977
  );
3965
3978
 
3966
3979
  const handleResolveReconcile = useCallback(
@@ -147,6 +147,16 @@ import { LinkHoverPreview } from "./LinkHoverPreview";
147
147
  import { SlashCommandMenu } from "./SlashCommandMenu";
148
148
  import { TableHoverControls } from "./TableHoverControls";
149
149
 
150
+ function compareDocumentBodyRevisions(
151
+ first: string,
152
+ second: string,
153
+ ): number | null {
154
+ const firstMatch = /^body:(0|[1-9]\d*):/.exec(first);
155
+ const secondMatch = /^body:(0|[1-9]\d*):/.exec(second);
156
+ if (!firstMatch || !secondMatch) return null;
157
+ return Number(firstMatch[1]) - Number(secondMatch[1]);
158
+ }
159
+
150
160
  /**
151
161
  * Override the paragraph node's markdown serialization so that empty
152
162
  * paragraphs survive round-trips. Without this, prosemirror-markdown
@@ -3648,6 +3658,7 @@ export function VisualEditor({
3648
3658
  contentRevision: propsPredateAcknowledgedRestore
3649
3659
  ? acknowledgedRestore!.contentRevision
3650
3660
  : contentRevision,
3661
+ compareContentRevisions: compareDocumentBodyRevisions,
3651
3662
  acknowledgedLocalSnapshot,
3652
3663
  collabContentRevision: propsPredateAcknowledgedRestore
3653
3664
  ? null
@@ -1,6 +1,6 @@
1
1
  import { planDocReconcile } from "@agent-native/toolkit/editor";
2
2
  import type { Document } from "@shared/api";
3
- import { nfmToDoc } from "@shared/nfm";
3
+ import { docToNfm, nfmToDoc } from "@shared/nfm";
4
4
  import { getSchema } from "@tiptap/core";
5
5
 
6
6
  import { isDocumentUpdateConflict } from "@/hooks/use-documents";
@@ -42,13 +42,15 @@ export async function saveDocumentWithRebase({
42
42
  };
43
43
  }): Promise<RebasedDocumentSaveResult> {
44
44
  let attemptedBase = base;
45
- const draft = content;
45
+ let candidate = content;
46
46
  const conflict = (): RebasedDocumentSaveResult => {
47
47
  const current = owner?.current();
48
48
  return {
49
49
  status: "conflict",
50
50
  localDraft:
51
- current && current.version !== owner!.version ? current.content : draft,
51
+ current && current.version !== owner!.version
52
+ ? current.content
53
+ : candidate,
52
54
  };
53
55
  };
54
56
  const confirmed = (document: Document): RebasedDocumentSaveResult => {
@@ -57,12 +59,12 @@ export async function saveDocumentWithRebase({
57
59
  return { status: "saved", document, content: document.content };
58
60
  };
59
61
  for (let attempt = 0; attempt <= 2; attempt++) {
60
- const saved = await persist(draft, attemptedBase);
62
+ const saved = await persist(candidate, attemptedBase);
61
63
  if (!isDocumentUpdateConflict(saved)) {
62
64
  return confirmed(saved);
63
65
  }
64
66
  const winner = saved.document;
65
- if (winner.content === draft && confirmsWrite(winner)) {
67
+ if (winner.content === candidate && confirmsWrite(winner)) {
66
68
  return confirmed(winner);
67
69
  }
68
70
  if (
@@ -75,16 +77,17 @@ export async function saveDocumentWithRebase({
75
77
  }
76
78
  try {
77
79
  contentSchema ??= getSchema(createVisualEditorExtensions());
78
- const localDoc = contentSchema.nodeFromJSON(nfmToDoc(draft));
80
+ const localDoc = contentSchema.nodeFromJSON(nfmToDoc(candidate));
79
81
  const plan = planDocReconcile(
80
82
  localDoc,
81
83
  contentSchema.nodeFromJSON(nfmToDoc(attemptedBase.content)),
82
84
  contentSchema.nodeFromJSON(nfmToDoc(winner.content)),
83
85
  );
84
- // Only advance the CAS base when the editor already contains the winner.
85
- // A server-only change must reach the live Y.Doc through its elected
86
- // reconciler before later keystrokes can safely include that change.
87
- if (plan.status !== "noop") {
86
+ // Carry an unambiguous three-way merge into the retry. Ambiguous edits
87
+ // still need recovery because choosing either side would discard work.
88
+ if (plan.status === "applied") {
89
+ candidate = docToNfm(plan.mergedDoc.toJSON());
90
+ } else if (plan.status !== "noop") {
88
91
  return conflict();
89
92
  }
90
93
  attemptedBase = {