@agent-native/core 0.181.0 → 0.181.1-nightly-20260916144018

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 (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/create-account-dialog.tsx +2 -1
  3. package/corpus/templates/clips/changelog/2026-09-15-google-sign-up-now-opens-reliably-in-embedded-browsers.md +6 -0
  4. package/corpus/templates/content/actions/resolve-preview-document-draft.ts +320 -60
  5. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +3 -1
  6. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +349 -104
  7. package/corpus/templates/content/app/components/editor/DocumentReconcileRecovery.tsx +109 -0
  8. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +31 -153
  9. package/corpus/templates/content/app/components/editor/RecoveryComparison.tsx +230 -0
  10. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +46 -19
  11. package/corpus/templates/content/app/components/editor/editor-draft-save.ts +13 -0
  12. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +248 -0
  13. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +2 -1
  14. package/corpus/templates/content/app/i18n-data.ts +13 -0
  15. package/corpus/templates/content/changelog/2026-09-15-oauth-connections-now-open-reliably-in-embedded-browsers.md +6 -0
  16. package/corpus/templates/content/docs/solutions/2026-09-02-content-suggested-edits-parity-shape.md +84 -2
  17. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +27 -7
  18. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +45 -15
  19. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +35 -8
  20. package/corpus/templates/design/app/components/design/design-canvas/element-payload.ts +14 -0
  21. package/corpus/templates/design/app/pages/DesignEditor.tsx +18 -5
  22. package/corpus/templates/design/app/pages/design-editor/commands/record-pending-live-structure-edit.ts +9 -4
  23. package/corpus/templates/design/app/pages/design-editor/commands/screen-visual-structure-change.ts +3 -0
  24. package/corpus/templates/design/app/pages/design-editor/commands/start-sidebar-resize.ts +7 -2
  25. package/corpus/templates/design/app/pages/design-editor/commands/visual-structure-change.ts +2 -0
  26. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +4 -0
  27. package/corpus/templates/design/app/pages/design-editor/pending-structure-verification.ts +197 -147
  28. package/corpus/templates/design/changelog/2026-09-16-design-editor-agent-chat-uses-shared-conversation-tabs-and-s.md +6 -0
  29. package/corpus/templates/design/changelog/2026-09-16-visual-edits-stay-applied-after-replacements.md +5 -0
  30. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +2 -1
  31. package/corpus/templates/mail/changelog/2026-09-15-google-connections-now-open-reliably-in-embedded-browsers.md +6 -0
  32. package/dist/client/AgentPanel.d.ts +1 -1
  33. package/dist/client/AgentPanel.js +4 -4
  34. package/dist/client/auth/AuthPage.js +6 -1
  35. package/dist/client/oauth-popup.d.ts +7 -0
  36. package/dist/client/oauth-popup.js +14 -0
  37. package/dist/client/ocean/OceanBackground.d.ts +3 -1
  38. package/dist/client/ocean/OceanBackground.js +4 -3
  39. package/dist/client/resources/mcp-integration-catalog.js +7 -3
  40. package/dist/client/settings/useBuilderStatus.js +4 -3
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/collab/struct-routes.d.ts +1 -1
  43. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  44. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  45. package/dist/notifications/routes.d.ts +3 -3
  46. package/dist/observability/routes.d.ts +3 -3
  47. package/dist/org/app-roles.d.ts +8 -0
  48. package/dist/org/app-roles.js +7 -0
  49. package/dist/org/index.d.ts +1 -1
  50. package/dist/org/index.js +1 -1
  51. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  52. package/dist/secrets/routes.d.ts +6 -6
  53. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  54. package/dist/server/auth.js +3 -0
  55. package/dist/server/core-routes-plugin.d.ts +3 -0
  56. package/dist/server/core-routes-plugin.js +17 -0
  57. package/dist/server/realtime-token.d.ts +1 -1
  58. package/package.json +5 -4
@@ -96,6 +96,7 @@ import {
96
96
  useDocument,
97
97
  useDeleteDocument,
98
98
  useDocuments,
99
+ useResolvePreviewDocumentDraft,
99
100
  useUpdatePreviewDocumentDraft,
100
101
  useUpdateDocument,
101
102
  } from "@/hooks/use-documents";
@@ -163,7 +164,9 @@ import { DocumentDatabase } from "./DocumentDatabase";
163
164
  import { DocumentEditorSkeleton } from "./DocumentEditorSkeleton";
164
165
  import { DocumentInfoPanel } from "./DocumentInfoPanel";
165
166
  import { DocumentProperties } from "./DocumentProperties";
167
+ import { DocumentReconcileRecovery } from "./DocumentReconcileRecovery";
166
168
  import { DocumentToolbar, type ToolbarBreadcrumbItem } from "./DocumentToolbar";
169
+ import type { EditorDraftSaveResult } from "./editor-draft-save";
167
170
  import { EmojiPicker } from "./EmojiPicker";
168
171
  import { LinkedLocalDocumentAgentBridge } from "./LinkedLocalDocumentAgentBridge";
169
172
  import {
@@ -200,6 +203,12 @@ import {
200
203
  normalizeTitleText,
201
204
  stripMarkdownHeadingPrefixFromTitlePaste,
202
205
  } from "./title-text";
206
+ import {
207
+ useDocumentReconcileRecovery,
208
+ type DocumentReconcileRecoveryState,
209
+ type ReconcileRecoveryDraft,
210
+ type ReconcileSaveBase,
211
+ } from "./useDocumentReconcileRecovery";
203
212
  import { VisualEditor } from "./VisualEditor";
204
213
  import type {
205
214
  NotionPageLink,
@@ -1043,8 +1052,8 @@ type DocumentSaveOptions = {
1043
1052
  historySessionId?: string;
1044
1053
  allowQueuedSave?: boolean;
1045
1054
  expectedLocalSourceRevision?: string | null;
1046
- adoptCurrentServerBase?: boolean;
1047
1055
  contentBase?: DocumentContentBase;
1056
+ titleBase?: string;
1048
1057
  contentEditVersion?: number;
1049
1058
  };
1050
1059
 
@@ -1492,6 +1501,7 @@ function PageEditorSessionBody({
1492
1501
  });
1493
1502
  }, [documentId, host, t]);
1494
1503
  const updateDocument = useUpdateDocument();
1504
+ const resolvePreviewDocumentDraft = useResolvePreviewDocumentDraft();
1495
1505
  const updatePreviewDocumentDraft = useUpdatePreviewDocumentDraft();
1496
1506
  const updatePreviewDocumentDraftRef = useRef(
1497
1507
  updatePreviewDocumentDraft.mutateAsync,
@@ -1701,9 +1711,20 @@ function PageEditorSessionBody({
1701
1711
  diskRevision?: DesktopContentFileRevision;
1702
1712
  unsavedText: string;
1703
1713
  } | null>(null);
1704
- const [documentReconcileConflict, setDocumentReconcileConflict] = useState<{
1705
- localDraft: string;
1706
- } | null>(null);
1714
+ const reconcileRecoveryStateRef =
1715
+ useRef<DocumentReconcileRecoveryState | null>(null);
1716
+ const reconcileSaveRef = useRef<
1717
+ (
1718
+ draft: ReconcileRecoveryDraft,
1719
+ base?: ReconcileSaveBase,
1720
+ ) => Promise<boolean>
1721
+ >(async () => false);
1722
+ const reconcileRetainRef = useRef<
1723
+ (draft: ReconcileRecoveryDraft) => Promise<void>
1724
+ >(async () => undefined);
1725
+ const reportReconcileRef = useRef<
1726
+ (reason: "conflict" | "failed", localDraft: string) => void
1727
+ >(() => undefined);
1707
1728
  const [localSourceMissing, setLocalSourceMissing] = useState(false);
1708
1729
  const [localSourceAccess, setLocalSourceAccess] = useState<
1709
1730
  "checking" | "available" | "unavailable"
@@ -1821,6 +1842,36 @@ function PageEditorSessionBody({
1821
1842
  const localContentRef = useRef(localContent);
1822
1843
  const contentEditVersionRef = useRef(0);
1823
1844
  localContentRef.current = localContent;
1845
+ const reconcileRecovery = useDocumentReconcileRecovery({
1846
+ save: (draft, base) => reconcileSaveRef.current(draft, base),
1847
+ retain: (draft) => reconcileRetainRef.current(draft),
1848
+ getDraft: () => localContentRef.current,
1849
+ getTitle: () => localTitleRef.current,
1850
+ getSaveIdentity: () =>
1851
+ JSON.stringify([localTitleRef.current, localContentRef.current]),
1852
+ stateRef: reconcileRecoveryStateRef,
1853
+ });
1854
+ const documentReconcileConflict = reconcileRecovery.state;
1855
+ const {
1856
+ report: reportReconcile,
1857
+ resolve: resolveReconcile,
1858
+ resolveChoice: resolveReconcileChoice,
1859
+ updateDraft: updateReconcileDraft,
1860
+ reportRetentionFailure,
1861
+ } = reconcileRecovery;
1862
+ const retainActiveRecoveryDraft = useCallback(
1863
+ (draft: ReconcileRecoveryDraft) => {
1864
+ void reconcileRetainRef.current(draft).catch(reportRetentionFailure);
1865
+ },
1866
+ [reportRetentionFailure],
1867
+ );
1868
+ const [acknowledgedLocalSnapshot, setAcknowledgedLocalSnapshot] = useState<{
1869
+ value: string;
1870
+ revision: string;
1871
+ updatedAt: string;
1872
+ sequence: number;
1873
+ } | null>(null);
1874
+ const acknowledgedLocalSnapshotSequenceRef = useRef(0);
1824
1875
  const getLinkedLocalEditorSnapshot = useCallback(
1825
1876
  () => ({
1826
1877
  title: localTitleRef.current,
@@ -1835,6 +1886,8 @@ function PageEditorSessionBody({
1835
1886
  documentUpdatedAtRef.current = document.updatedAt ?? null;
1836
1887
  const documentContentRef = useRef(document.content);
1837
1888
  documentContentRef.current = document.content;
1889
+ const documentTitleRef = useRef(document.title);
1890
+ documentTitleRef.current = document.title;
1838
1891
  const documentRevisionRef = useRef(document.revision);
1839
1892
  documentRevisionRef.current = document.revision;
1840
1893
  const handleBackgroundSaveError = useCallback(
@@ -2114,6 +2167,7 @@ function PageEditorSessionBody({
2114
2167
  useEffect(() => {
2115
2168
  if (!document || !isInitializedRef.current) return;
2116
2169
  if (isLinkedLocalSourceDocument) return;
2170
+ if (reconcileRecoveryStateRef.current) return;
2117
2171
  const serverTitle = document.title;
2118
2172
  const lastSaved = lastSavedTitleRef.current;
2119
2173
  if (serverTitle === lastSaved.title) {
@@ -2405,6 +2459,23 @@ function PageEditorSessionBody({
2405
2459
  persistenceErrorsRef.current.set(field, error);
2406
2460
  }
2407
2461
  } else {
2462
+ if (
2463
+ updates.content !== undefined &&
2464
+ result.revision &&
2465
+ result.updatedAt
2466
+ ) {
2467
+ const snapshot = {
2468
+ value: result.content,
2469
+ revision: result.revision,
2470
+ updatedAt: result.updatedAt,
2471
+ sequence: ++acknowledgedLocalSnapshotSequenceRef.current,
2472
+ };
2473
+ setAcknowledgedLocalSnapshot((current) =>
2474
+ !current || snapshot.sequence > current.sequence
2475
+ ? snapshot
2476
+ : current,
2477
+ );
2478
+ }
2408
2479
  if (
2409
2480
  result.updatedAt &&
2410
2481
  (!documentUpdatedAtRef.current ||
@@ -2591,13 +2662,6 @@ function PageEditorSessionBody({
2591
2662
  ): Promise<DocumentSaveResult> => {
2592
2663
  const contentEditVersion =
2593
2664
  options.contentEditVersion ?? contentEditVersionRef.current;
2594
- if (options.adoptCurrentServerBase) {
2595
- lastSavedContentRef.current = {
2596
- content: documentContentRef.current,
2597
- updatedAt: documentUpdatedAtRef.current,
2598
- revision: document.revision,
2599
- };
2600
- }
2601
2665
  lastSavedContentRef.current = refreshUnchangedContentSaveWatermark({
2602
2666
  serverContent: documentContentRef.current,
2603
2667
  serverUpdatedAt: documentUpdatedAtRef.current,
@@ -2608,19 +2672,35 @@ function PageEditorSessionBody({
2608
2672
  // stale — content. Guard per-field using the field's own watermark.
2609
2673
  const titleIsStale =
2610
2674
  !isLinkedLocalSourceDocument &&
2675
+ options.titleBase === undefined &&
2611
2676
  documentUpdatedAtRef.current &&
2612
2677
  lastSavedTitleRef.current.updatedAt &&
2613
2678
  documentUpdatedAtRef.current > lastSavedTitleRef.current.updatedAt;
2679
+ const contentBase = options.contentBase ?? lastSavedContentRef.current;
2680
+ if (
2681
+ options.titleBase !== undefined &&
2682
+ documentTitleRef.current !== options.titleBase
2683
+ ) {
2684
+ return { contentPersisted: false };
2685
+ }
2686
+ if (
2687
+ options.contentBase &&
2688
+ (documentContentRef.current !== options.contentBase.content ||
2689
+ documentUpdatedAtRef.current !== options.contentBase.updatedAt ||
2690
+ documentRevisionRef.current !== options.contentBase.revision)
2691
+ ) {
2692
+ return { contentPersisted: false };
2693
+ }
2614
2694
  const contentIsStale =
2615
2695
  !isLinkedLocalSourceDocument &&
2616
2696
  !!documentRevisionRef.current &&
2617
- !!lastSavedContentRef.current.revision &&
2618
- documentRevisionRef.current !== lastSavedContentRef.current.revision;
2697
+ !!contentBase.revision &&
2698
+ documentRevisionRef.current !== contentBase.revision;
2619
2699
 
2620
2700
  const updates: Record<string, string> = {};
2621
2701
  if (title !== lastSavedTitleRef.current.title && !titleIsStale)
2622
2702
  updates.title = title;
2623
- const contentChanged = content !== lastSavedContentRef.current.content;
2703
+ const contentChanged = content !== contentBase.content;
2624
2704
  if (contentChanged && !contentIsStale) updates.content = content;
2625
2705
  if (Object.keys(updates).length === 0) {
2626
2706
  return { contentPersisted: !contentChanged };
@@ -2637,7 +2717,7 @@ function PageEditorSessionBody({
2637
2717
  let result;
2638
2718
  try {
2639
2719
  result = await saveDocumentWithRebase({
2640
- base: { ...lastSavedContentRef.current },
2720
+ base: { ...contentBase },
2641
2721
  content,
2642
2722
  owner: {
2643
2723
  version: contentEditVersion,
@@ -2648,7 +2728,6 @@ function PageEditorSessionBody({
2648
2728
  confirm: (confirmedContent) => {
2649
2729
  localContentRef.current = confirmedContent;
2650
2730
  setLocalContent(confirmedContent);
2651
- setDocumentReconcileConflict(null);
2652
2731
  },
2653
2732
  },
2654
2733
  canRetry: (winner) =>
@@ -2667,9 +2746,7 @@ function PageEditorSessionBody({
2667
2746
  activeContentSavesRef.current -= 1;
2668
2747
  }
2669
2748
  if (result.status === "conflict") {
2670
- setDocumentReconcileConflict({
2671
- localDraft: result.localDraft,
2672
- });
2749
+ reportReconcileRef.current("conflict", result.localDraft);
2673
2750
  return { contentPersisted: false };
2674
2751
  }
2675
2752
  saved = result.document;
@@ -2785,6 +2862,21 @@ function PageEditorSessionBody({
2785
2862
  },
2786
2863
  [documentId, t],
2787
2864
  );
2865
+ const recoveryDraftRetentionQueueRef = useRef<Promise<void>>(
2866
+ Promise.resolve(),
2867
+ );
2868
+ const queueRecoveryDraftRetention = useCallback(
2869
+ (title: string, content: string, deferredReason: "conflict" | null) => {
2870
+ const retain = () => retainRecoveryDraft(title, content, deferredReason);
2871
+ const queued = recoveryDraftRetentionQueueRef.current.then(
2872
+ retain,
2873
+ retain,
2874
+ );
2875
+ recoveryDraftRetentionQueueRef.current = queued.catch(() => undefined);
2876
+ return queued;
2877
+ },
2878
+ [retainRecoveryDraft],
2879
+ );
2788
2880
  const clearRecoveryDraft = useCallback(
2789
2881
  async (persistedTitle: string, persistedContent: string) => {
2790
2882
  const current = recoveryDraftRef.current;
@@ -2846,14 +2938,11 @@ function PageEditorSessionBody({
2846
2938
  )
2847
2939
  .then((result) => {
2848
2940
  if (!result.contentPersisted) {
2849
- setDocumentReconcileConflict(
2850
- (current) =>
2851
- current ?? {
2852
- localDraft:
2853
- contentEditVersionRef.current === pending.contentEditVersion
2854
- ? pending.content
2855
- : localContentRef.current,
2856
- },
2941
+ reportReconcileRef.current(
2942
+ "conflict",
2943
+ contentEditVersionRef.current === pending.contentEditVersion
2944
+ ? pending.content
2945
+ : localContentRef.current,
2857
2946
  );
2858
2947
  }
2859
2948
  })
@@ -2968,6 +3057,7 @@ function PageEditorSessionBody({
2968
3057
  const debouncedSave = useCallback(
2969
3058
  (title: string, content: string) => {
2970
3059
  if (!canEditRef.current) return;
3060
+ if (reconcileRecoveryStateRef.current) return;
2971
3061
  const expectedLocalSourceRevision = isLinkedLocalSourceDocument
2972
3062
  ? localSourceRevisionForQueuedEdit(
2973
3063
  pendingDocumentSaveRef.current?.expectedLocalSourceRevision,
@@ -3310,6 +3400,13 @@ function PageEditorSessionBody({
3310
3400
  return;
3311
3401
  localTitleRef.current = newTitle;
3312
3402
  setLocalTitle(newTitle);
3403
+ if (updateReconcileDraft(localContentRef.current, newTitle)) {
3404
+ retainActiveRecoveryDraft({
3405
+ localTitle: newTitle,
3406
+ localDraft: localContentRef.current,
3407
+ });
3408
+ return;
3409
+ }
3313
3410
  patchDocumentCaches(queryClient, documentId, { title: newTitle });
3314
3411
  // Renames must not leave a stale optimistic title for the next landing.
3315
3412
  refreshLandingTitleHintCache(queryClient, documentId, newTitle);
@@ -3319,22 +3416,15 @@ function PageEditorSessionBody({
3319
3416
  void rememberContentLandingDocument(documentId, newTitle).catch(() => {});
3320
3417
  debouncedSave(newTitle, localContentRef.current);
3321
3418
  },
3322
- [debouncedSave, documentId, editorCanEdit, isSuggesting, queryClient],
3323
- );
3324
-
3325
- const handleContentChange = useCallback(
3326
- (newContent: string) => {
3327
- if (!editorCanEdit) return;
3328
- contentEditVersionRef.current += 1;
3329
- localContentRef.current = newContent;
3330
- setLocalContent(newContent);
3331
- if (documentReconcileConflict) {
3332
- setDocumentReconcileConflict({ localDraft: newContent });
3333
- return;
3334
- }
3335
- debouncedSave(localTitleRef.current, newContent);
3336
- },
3337
- [debouncedSave, documentReconcileConflict, editorCanEdit],
3419
+ [
3420
+ debouncedSave,
3421
+ documentId,
3422
+ editorCanEdit,
3423
+ isSuggesting,
3424
+ queryClient,
3425
+ retainActiveRecoveryDraft,
3426
+ updateReconcileDraft,
3427
+ ],
3338
3428
  );
3339
3429
 
3340
3430
  const savedSuggestions = useMemo(() => {
@@ -3753,53 +3843,223 @@ function PageEditorSessionBody({
3753
3843
  ]);
3754
3844
 
3755
3845
  const handleContentSaveNow = useCallback(
3756
- async (newContent: string, adoptCurrentServerBase = false) => {
3846
+ async (
3847
+ recovery: ReconcileRecoveryDraft,
3848
+ reconcileBase?: ReconcileSaveBase,
3849
+ ) => {
3757
3850
  if (!editorCanEdit) return false;
3758
3851
  contentEditVersionRef.current += 1;
3759
- const contentEditVersion = contentEditVersionRef.current;
3760
3852
  if (saveTimeoutRef.current) {
3761
3853
  clearTimeout(saveTimeoutRef.current);
3762
3854
  saveTimeoutRef.current = null;
3763
3855
  pendingDocumentSaveRef.current = null;
3764
3856
  }
3765
- localContentRef.current = newContent;
3766
- setLocalContent(newContent);
3857
+ localTitleRef.current = recovery.localTitle;
3858
+ localContentRef.current = recovery.localDraft;
3859
+ setLocalTitle(recovery.localTitle);
3860
+ setLocalContent(recovery.localDraft);
3767
3861
  const result = await queueDocumentSave(
3768
- localTitleRef.current,
3769
- newContent,
3862
+ recovery.localTitle,
3863
+ recovery.localDraft,
3770
3864
  {
3771
- adoptCurrentServerBase,
3865
+ contentBase: reconcileBase
3866
+ ? {
3867
+ content: reconcileBase.content,
3868
+ updatedAt: reconcileBase.updatedAt,
3869
+ revision: reconcileBase.revision,
3870
+ }
3871
+ : undefined,
3872
+ titleBase: reconcileBase?.title,
3772
3873
  },
3773
3874
  );
3774
- if (!result.contentPersisted) {
3775
- setDocumentReconcileConflict(
3776
- (current) =>
3777
- current ?? {
3778
- localDraft:
3779
- contentEditVersionRef.current === contentEditVersion
3780
- ? newContent
3781
- : localContentRef.current,
3782
- },
3783
- );
3784
- } else if (contentEditVersionRef.current === contentEditVersion) {
3785
- setDocumentReconcileConflict(null);
3786
- }
3787
3875
  return result.contentPersisted;
3788
3876
  },
3789
3877
  [editorCanEdit, queueDocumentSave],
3790
3878
  );
3879
+ reconcileSaveRef.current = handleContentSaveNow;
3880
+ reconcileRetainRef.current = ({ localTitle, localDraft }) =>
3881
+ queueRecoveryDraftRetention(
3882
+ localTitle,
3883
+ localDraft,
3884
+ reconcileRecoveryStateRef.current?.reason === "conflict"
3885
+ ? "conflict"
3886
+ : null,
3887
+ );
3888
+ reportReconcileRef.current = reportReconcile;
3889
+
3890
+ const handleContentChange = useCallback(
3891
+ (newContent: string) => {
3892
+ if (!editorCanEdit) return;
3893
+ contentEditVersionRef.current += 1;
3894
+ localContentRef.current = newContent;
3895
+ setLocalContent(newContent);
3896
+ if (updateReconcileDraft(newContent)) {
3897
+ retainActiveRecoveryDraft({
3898
+ localTitle: localTitleRef.current,
3899
+ localDraft: newContent,
3900
+ });
3901
+ return;
3902
+ }
3903
+ debouncedSave(localTitleRef.current, newContent);
3904
+ },
3905
+ [
3906
+ debouncedSave,
3907
+ editorCanEdit,
3908
+ retainActiveRecoveryDraft,
3909
+ updateReconcileDraft,
3910
+ ],
3911
+ );
3912
+
3913
+ const handleImmediateContentChange = useCallback(
3914
+ async (newContent: string): Promise<EditorDraftSaveResult> => {
3915
+ if (!editorCanEdit) return "failed";
3916
+ if (reconcileRecoveryStateRef.current) {
3917
+ handleContentChange(newContent);
3918
+ return "retained";
3919
+ }
3920
+ return (await handleContentSaveNow({
3921
+ localTitle: localTitleRef.current,
3922
+ localDraft: newContent,
3923
+ }))
3924
+ ? "persisted"
3925
+ : "failed";
3926
+ },
3927
+ [editorCanEdit, handleContentChange, handleContentSaveNow],
3928
+ );
3791
3929
 
3792
3930
  const handleBaseAwareReconcile = useCallback(
3793
- (result: { status: "merged" | "conflict" | "failed"; content: string }) => {
3931
+ (result: {
3932
+ status: "merged" | "conflict" | "failed";
3933
+ content: string;
3934
+ serverContent: string;
3935
+ serverRevision: string;
3936
+ }) => {
3937
+ if (saveTimeoutRef.current) {
3938
+ clearTimeout(saveTimeoutRef.current);
3939
+ saveTimeoutRef.current = null;
3940
+ pendingDocumentSaveRef.current = null;
3941
+ }
3942
+ localContentRef.current = result.content;
3943
+ 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
+ });
3794
3952
  if (result.status === "merged") {
3795
- void handleContentSaveNow(result.content, true);
3796
- return;
3953
+ if (documentContentRef.current === result.serverContent) {
3954
+ void resolveReconcile({
3955
+ title: document.title,
3956
+ content: result.serverContent,
3957
+ updatedAt: documentUpdatedAtRef.current,
3958
+ revision: result.serverRevision,
3959
+ });
3960
+ }
3797
3961
  }
3798
- setDocumentReconcileConflict({ localDraft: result.content });
3799
3962
  },
3800
- [handleContentSaveNow],
3963
+ [reportReconcile, resolveReconcile, retainActiveRecoveryDraft],
3964
+ );
3965
+
3966
+ const handleResolveReconcile = useCallback(
3967
+ async (base: ReconcileSaveBase) => {
3968
+ const saved = await resolveReconcile(base);
3969
+ if (saved)
3970
+ requestAnimationFrame(() => {
3971
+ documentLayoutRef.current
3972
+ ?.querySelector<HTMLElement>(".ProseMirror")
3973
+ ?.focus({ preventScroll: true });
3974
+ });
3975
+ return saved;
3976
+ },
3977
+ [resolveReconcile],
3978
+ );
3979
+
3980
+ const resolveLiveRecoveryDraft = useCallback(
3981
+ async (
3982
+ choice: "use_saved" | "save_separately",
3983
+ base: ReconcileSaveBase,
3984
+ ) => {
3985
+ let resolvedUrlPath: string | undefined;
3986
+ const resolved = await resolveReconcileChoice(
3987
+ base,
3988
+ async (recovery, reviewedBase) => {
3989
+ await retainRecoveryDraft(
3990
+ recovery.localTitle,
3991
+ recovery.localDraft,
3992
+ documentReconcileConflict?.reason === "conflict"
3993
+ ? "conflict"
3994
+ : null,
3995
+ );
3996
+ const draft = recoveryDraftRef.current;
3997
+ if (!draft || !reviewedBase.updatedAt) return false;
3998
+ const result = await resolvePreviewDocumentDraft.mutateAsync({
3999
+ choice,
4000
+ documentId,
4001
+ expectedDraftVersion: draft.version,
4002
+ expectedDraftTitle: draft.title,
4003
+ expectedDraftContent: draft.content,
4004
+ expectedDocumentUpdatedAt: reviewedBase.updatedAt,
4005
+ });
4006
+ if (result.status === "document_conflict") {
4007
+ await queryClient.refetchQueries(documentQueryFilter(documentId));
4008
+ return false;
4009
+ }
4010
+ if (
4011
+ recoveryDraftRef.current?.version === draft.version &&
4012
+ recoveryDraftRef.current.title === draft.title &&
4013
+ recoveryDraftRef.current.content === draft.content
4014
+ ) {
4015
+ recoveryDraftRef.current = null;
4016
+ }
4017
+ resolvedUrlPath = result.urlPath;
4018
+ return true;
4019
+ },
4020
+ );
4021
+ if (!resolved) return false;
4022
+ if (choice === "save_separately" && resolvedUrlPath) {
4023
+ toast.success(t("editor.previewDraftSavedSeparately"));
4024
+ void navigate(resolvedUrlPath);
4025
+ return true;
4026
+ }
4027
+ if (choice === "use_saved") {
4028
+ localTitleRef.current = base.title ?? document.title;
4029
+ localContentRef.current = base.content;
4030
+ setLocalTitle(base.title ?? document.title);
4031
+ setLocalContent(base.content);
4032
+ await queryClient.refetchQueries(documentQueryFilter(documentId));
4033
+ toast.success(t("editor.previewDraftSavedToHistory"));
4034
+ }
4035
+ return true;
4036
+ },
4037
+ [
4038
+ documentId,
4039
+ documentReconcileConflict?.reason,
4040
+ navigate,
4041
+ queryClient,
4042
+ resolveReconcileChoice,
4043
+ resolvePreviewDocumentDraft,
4044
+ retainRecoveryDraft,
4045
+ t,
4046
+ ],
3801
4047
  );
3802
4048
 
4049
+ const copyLiveRecoveryDraft = useCallback(async () => {
4050
+ const copied = await writeClipboardText(localContentRef.current);
4051
+ if (copied) toast.success(t("editor.unsavedTextCopied"));
4052
+ else toast.error(t("editor.toolbar.clipboardAccessUnavailable"));
4053
+ }, [t]);
4054
+
4055
+ const focusEditorAfterRecoveryReview = useCallback(() => {
4056
+ requestAnimationFrame(() => {
4057
+ documentLayoutRef.current
4058
+ ?.querySelector<HTMLElement>(".ProseMirror")
4059
+ ?.focus({ preventScroll: true });
4060
+ });
4061
+ }, []);
4062
+
3803
4063
  const useDiskVersion = useCallback(() => {
3804
4064
  if (!localSourceConflict) return;
3805
4065
  const next = localSourceConflict.diskDocument;
@@ -4868,41 +5128,23 @@ function PageEditorSessionBody({
4868
5128
  ) : null}
4869
5129
 
4870
5130
  {documentReconcileConflict ? (
4871
- <div
4872
- className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2 text-sm"
4873
- role="alert"
4874
- data-document-reconcile-conflict
4875
- >
4876
- <span className="me-auto">{t("editor.toolbar.conflict")}</span>
4877
- <Button
4878
- type="button"
4879
- size="sm"
4880
- variant="ghost"
4881
- onClick={() => {
4882
- void writeClipboardText(
4883
- documentReconcileConflict.localDraft,
4884
- ).then((copied) => {
4885
- if (copied) toast.success(t("editor.unsavedTextCopied"));
4886
- else
4887
- toast.error(
4888
- t("editor.toolbar.clipboardAccessUnavailable"),
4889
- );
4890
- });
4891
- }}
4892
- >
4893
- {t("editor.copyUnsavedText")}
4894
- </Button>
4895
- <Button
4896
- type="button"
4897
- size="sm"
4898
- onClick={() => {
4899
- const localDraft = documentReconcileConflict.localDraft;
4900
- void handleContentSaveNow(localDraft, true);
4901
- }}
4902
- >
4903
- {t("editor.keepLocalDraft")}
4904
- </Button>
4905
- </div>
5131
+ <DocumentReconcileRecovery
5132
+ state={documentReconcileConflict}
5133
+ server={{
5134
+ title: document.title,
5135
+ content: document.content,
5136
+ updatedAt: document.updatedAt ?? null,
5137
+ revision: document.revision,
5138
+ }}
5139
+ canEdit={editorCanEdit}
5140
+ onKeepMine={handleResolveReconcile}
5141
+ onUseSaved={(base) => resolveLiveRecoveryDraft("use_saved", base)}
5142
+ onSaveSeparately={(base) =>
5143
+ resolveLiveRecoveryDraft("save_separately", base)
5144
+ }
5145
+ onCopy={copyLiveRecoveryDraft}
5146
+ onClose={focusEditorAfterRecoveryReview}
5147
+ />
4906
5148
  ) : null}
4907
5149
 
4908
5150
  {isSuggesting &&
@@ -5280,6 +5522,9 @@ function PageEditorSessionBody({
5280
5522
  ? null
5281
5523
  : (document.revision ?? null)
5282
5524
  }
5525
+ acknowledgedLocalSnapshot={
5526
+ acknowledgedLocalSnapshot
5527
+ }
5283
5528
  onBaseAwareReconcile={handleBaseAwareReconcile}
5284
5529
  collabContentRevision={
5285
5530
  isLocalFileDocument || isSuggesting
@@ -5294,7 +5539,7 @@ function PageEditorSessionBody({
5294
5539
  }
5295
5540
  onSaveContent={
5296
5541
  suggestionEditorIsolation.persistCanonical
5297
- ? handleContentSaveNow
5542
+ ? handleImmediateContentChange
5298
5543
  : undefined
5299
5544
  }
5300
5545
  ydoc={