@agent-native/core 0.164.11 → 0.164.15

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 (106) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +84 -0
  3. package/corpus/templates/calendar/AGENTS.md +5 -0
  4. package/corpus/templates/calendar/actions/delete-event.ts +1 -1
  5. package/corpus/templates/calendar/actions/delete-events.ts +508 -0
  6. package/corpus/templates/calendar/actions/event-action-helpers.ts +1 -1
  7. package/corpus/templates/calendar/actions/list-events.ts +53 -0
  8. package/corpus/templates/calendar/changelog/2026-08-18-the-agent-can-now-remove-many-meetings-at-once-such-as-every.md +6 -0
  9. package/corpus/templates/calendar/server/lib/event-weekday.ts +112 -0
  10. package/corpus/templates/content/actions/_database-source-utils.ts +25 -2
  11. package/corpus/templates/content/actions/_database-utils.ts +2 -0
  12. package/corpus/templates/content/actions/_local-folder-source.ts +186 -2
  13. package/corpus/templates/content/actions/connect-local-folder-source.ts +23 -8
  14. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +3 -3
  15. package/corpus/templates/content/actions/resolve-local-folder-conflict.ts +2 -2
  16. package/corpus/templates/content/actions/sync-local-folder-source.ts +130 -12
  17. package/corpus/templates/content/app/components/LocalFolderLiveSync.tsx +77 -0
  18. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +354 -8
  19. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +23 -1
  20. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +78 -1
  21. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +36 -0
  22. package/corpus/templates/content/app/components/editor/local-source-write-state.ts +52 -0
  23. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +244 -6
  24. package/corpus/templates/content/app/components/sidebar/local-source-hierarchy.ts +189 -0
  25. package/corpus/templates/content/app/hooks/use-create-page.ts +26 -4
  26. package/corpus/templates/content/app/hooks/use-documents.ts +83 -0
  27. package/corpus/templates/content/app/i18n-data.ts +79 -0
  28. package/corpus/templates/content/app/lib/desktop-content-files.ts +57 -1
  29. package/corpus/templates/content/app/lib/local-content-source-files.ts +210 -34
  30. package/corpus/templates/content/app/lib/local-folder-live-sync.ts +191 -0
  31. package/corpus/templates/content/app/root.tsx +2 -1
  32. package/corpus/templates/content/app/routes/_app.local-files.tsx +103 -38
  33. package/corpus/templates/content/docs/product/capabilities/content.source.file-folder.md +2 -2
  34. package/corpus/templates/content/docs/product/features/content.feature.bring-your-local-work.md +3 -3
  35. package/corpus/templates/content/docs/product/roadmap.md +2 -2
  36. package/corpus/templates/content/docs/solutions/2026-08-18-content-navigation-coherence-shape.md +482 -0
  37. package/corpus/templates/content/shared/api.ts +25 -0
  38. package/corpus/templates/factory/AGENTS.md +2 -1
  39. package/corpus/templates/factory/actions/get-factory-graph-version.ts +69 -0
  40. package/corpus/templates/factory/actions/list-factory-graph-versions.ts +82 -0
  41. package/corpus/templates/factory/actions/restore-factory-graph-version.ts +163 -0
  42. package/corpus/templates/factory/actions/save-factory-graph.ts +93 -60
  43. package/corpus/templates/factory/app/components/factory/FactoryHistoryView.tsx +709 -0
  44. package/corpus/templates/factory/app/components/factory/FactoryInspector.tsx +62 -2
  45. package/corpus/templates/factory/app/components/ui/alert-dialog.tsx +1 -0
  46. package/corpus/templates/factory/app/i18n/en-US.ts +43 -0
  47. package/corpus/templates/factory/app/routes/factory.tsx +115 -15
  48. package/corpus/templates/factory/server/plugins/agent-chat.ts +3 -1
  49. package/dist/app-config/auth.d.ts +4 -0
  50. package/dist/app-config/auth.js +7 -0
  51. package/dist/app-config/schema.d.ts +3 -0
  52. package/dist/app-config/schema.js +2 -0
  53. package/dist/cli/code-agent-executor.js +6 -1
  54. package/dist/cli/code-agent-mcp-config.d.ts +2 -2
  55. package/dist/cli/code-agent-mcp-config.js +34 -9
  56. package/dist/client/org/OrgSwitcher.js +8 -4
  57. package/dist/collab/awareness.d.ts +2 -2
  58. package/dist/collab/routes.d.ts +1 -1
  59. package/dist/collab/struct-routes.d.ts +1 -1
  60. package/dist/mcp-client/config.d.ts +11 -0
  61. package/dist/mcp-client/config.js +33 -26
  62. package/dist/notifications/routes.d.ts +3 -3
  63. package/dist/observability/routes.d.ts +3 -3
  64. package/dist/progress/routes.d.ts +1 -1
  65. package/dist/resources/handlers.d.ts +2 -2
  66. package/dist/secrets/routes.d.ts +9 -9
  67. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  68. package/dist/server/auth.js +15 -1
  69. package/dist/server/realtime-token.d.ts +1 -1
  70. package/docs/content/actions-access-control.mdx +136 -2
  71. package/docs/content/actions-agent-tools.mdx +101 -0
  72. package/docs/content/actions-defining.mdx +2 -2
  73. package/docs/content/audit-log.mdx +1 -1
  74. package/docs/content/authentication.mdx +1 -1
  75. package/docs/content/cloudflare.mdx +43 -0
  76. package/docs/content/creating-templates.mdx +1 -1
  77. package/docs/content/deploy-an-app.mdx +109 -0
  78. package/docs/content/deployment-environment-variables.mdx +101 -0
  79. package/docs/content/deployment.mdx +125 -792
  80. package/docs/content/docs-components.mdx +2 -2
  81. package/docs/content/environment-variables.mdx +41 -41
  82. package/docs/content/file-uploads.mdx +74 -40
  83. package/docs/content/harness-agents.mdx +2 -2
  84. package/docs/content/internationalization.mdx +61 -65
  85. package/docs/content/key-concepts.mdx +3 -3
  86. package/docs/content/multi-app-workspace.mdx +2 -2
  87. package/docs/content/netlify.mdx +47 -0
  88. package/docs/content/node-docker.mdx +138 -0
  89. package/docs/content/organizations-teams-permissions.mdx +5 -5
  90. package/docs/content/other-platforms.mdx +27 -0
  91. package/docs/content/ssr-caching.mdx +45 -0
  92. package/docs/content/template-assets-developers.mdx +2 -2
  93. package/docs/content/template-chat-developers.mdx +2 -2
  94. package/docs/content/template-content-developers.mdx +2 -2
  95. package/docs/content/{local-file-mode.mdx → template-content-local-files.mdx} +2 -1
  96. package/docs/content/template-content-sync.mdx +2 -2
  97. package/docs/content/toolkit-agent-ux.mdx +1 -1
  98. package/docs/content/toolkit-resources.mdx +2 -2
  99. package/docs/content/updating-ui-in-production.mdx +35 -0
  100. package/docs/content/vercel.mdx +38 -0
  101. package/docs/content/what-is-agent-native.mdx +1 -1
  102. package/docs/content/workspace-deployment.mdx +350 -0
  103. package/package.json +1 -1
  104. package/docs/content/agents.mdx +0 -9
  105. package/docs/content/database.mdx +0 -317
  106. package/docs/content/human-approval.mdx +0 -156
@@ -1,6 +1,7 @@
1
1
  import { BlockRegistryProvider } from "@agent-native/core/blocks";
2
2
  import { generateTabId } from "@agent-native/core/client/agent-chat";
3
3
  import { agentNativePath } from "@agent-native/core/client/api-path";
4
+ import { writeClipboardText } from "@agent-native/core/client/clipboard";
4
5
  import {
5
6
  useCollaborativeDoc,
6
7
  emailToColor,
@@ -79,8 +80,11 @@ import {
79
80
  usePushDocumentToNotion,
80
81
  } from "@/hooks/use-notion";
81
82
  import { rememberContentLandingDocument } from "@/lib/content-landing";
83
+ import type { DesktopContentFileRevision } from "@/lib/desktop-content-files";
82
84
  import {
83
85
  canWriteLinkedLocalSource,
86
+ readDocumentFromLinkedLocalSource,
87
+ watchLinkedLocalSource,
84
88
  writeDocumentToLinkedLocalSource,
85
89
  } from "@/lib/local-content-source-files";
86
90
  import { isDatabaseChoicePending } from "@/lib/optimistic-document";
@@ -100,13 +104,23 @@ import { DocumentEditorSkeleton } from "./DocumentEditorSkeleton";
100
104
  import { DocumentInfoPanel } from "./DocumentInfoPanel";
101
105
  import { DocumentToolbar, type ToolbarBreadcrumbItem } from "./DocumentToolbar";
102
106
  import { EmojiPicker } from "./EmojiPicker";
107
+ import {
108
+ classifyLocalSourceRead,
109
+ localSourceRevisionForQueuedEdit,
110
+ localSourceRevisionForSave,
111
+ type PendingLocalSourceWrite,
112
+ } from "./local-source-write-state";
103
113
  import { NotionConflictBanner } from "./NotionConflictBanner";
104
114
  import {
105
115
  normalizeTitleText,
106
116
  stripMarkdownHeadingPrefixFromTitlePaste,
107
117
  } from "./title-text";
108
118
  import { VisualEditor } from "./VisualEditor";
109
- import type { NotionPageLink } from "./VisualEditor";
119
+ import type {
120
+ NotionPageLink,
121
+ VisualEditorHistoryController,
122
+ VisualEditorHistoryState,
123
+ } from "./VisualEditor";
110
124
 
111
125
  const TAB_ID = generateTabId();
112
126
 
@@ -303,6 +317,25 @@ export function shouldAwaitAuthoritativeDocument({
303
317
  return isFetching && !isFetchedAfterMount;
304
318
  }
305
319
 
320
+ export function visualEditorInstanceKey(args: {
321
+ documentId: string;
322
+ documentUpdatedAt: string | null;
323
+ isLocalFileDocument: boolean;
324
+ canEdit: boolean;
325
+ collabEditorEnabled: boolean;
326
+ hasYDoc: boolean;
327
+ localFileSyncRevision?: number;
328
+ }) {
329
+ const mode = args.isLocalFileDocument
330
+ ? `local-file:${args.localFileSyncRevision ?? 0}`
331
+ : args.collabEditorEnabled && args.hasYDoc
332
+ ? "live-ready"
333
+ : args.canEdit
334
+ ? "live-pending"
335
+ : `snapshot:${args.documentUpdatedAt}`;
336
+ return `${args.documentId}:${mode}`;
337
+ }
338
+
306
339
  interface DocumentEditorBodyProps {
307
340
  documentId: string;
308
341
  document: Document;
@@ -319,11 +352,13 @@ type PendingDocumentSave = {
319
352
  options?: DocumentSaveOptions,
320
353
  ) => unknown | Promise<unknown>;
321
354
  canEditWhenQueued: boolean;
355
+ expectedLocalSourceRevision?: string | null;
322
356
  timeout: ReturnType<typeof setTimeout>;
323
357
  };
324
358
 
325
359
  type DocumentSaveOptions = {
326
360
  allowQueuedSave?: boolean;
361
+ expectedLocalSourceRevision?: string | null;
327
362
  };
328
363
 
329
364
  type DocumentSaveResult = {
@@ -605,6 +640,42 @@ function DocumentEditorBody({
605
640
  const [localContentUpdatedAt, setLocalContentUpdatedAt] = useState<
606
641
  string | null
607
642
  >(document.updatedAt ?? null);
643
+ const localSourceRevisionRef = useRef<DesktopContentFileRevision | undefined>(
644
+ undefined,
645
+ );
646
+ const pendingLocalSourceWriteRef = useRef<PendingLocalSourceWrite | null>(
647
+ null,
648
+ );
649
+ const [localSourceConflict, setLocalSourceConflict] = useState<{
650
+ diskDocument: Document;
651
+ diskRevision?: DesktopContentFileRevision;
652
+ unsavedText: string;
653
+ } | null>(null);
654
+ const [localSourceMissing, setLocalSourceMissing] = useState(false);
655
+ const [localSourceAccess, setLocalSourceAccess] = useState<
656
+ "checking" | "available" | "unavailable"
657
+ >("checking");
658
+ const [localFileSyncRevision, setLocalFileSyncRevision] = useState(0);
659
+ const editorHistoryControllerRef =
660
+ useRef<VisualEditorHistoryController | null>(null);
661
+ const [editorHistoryState, setEditorHistoryState] =
662
+ useState<VisualEditorHistoryState>({ canUndo: false, canRedo: false });
663
+ const handleHistoryStateChange = useCallback(
664
+ (next: VisualEditorHistoryState) => {
665
+ setEditorHistoryState((current) =>
666
+ current.canUndo === next.canUndo && current.canRedo === next.canRedo
667
+ ? current
668
+ : next,
669
+ );
670
+ },
671
+ [],
672
+ );
673
+ const handleHistoryControllerChange = useCallback(
674
+ (controller: VisualEditorHistoryController | null) => {
675
+ editorHistoryControllerRef.current = controller;
676
+ },
677
+ [],
678
+ );
608
679
  const handleDeleteDocument = useCallback(async () => {
609
680
  try {
610
681
  if (document.database) {
@@ -777,7 +848,11 @@ function DocumentEditorBody({
777
848
  });
778
849
  const bodyHydrationPending = documentBodyHydrationIsPending(document);
779
850
  const editorCanEdit =
780
- canEdit && !bodyHydrationPending && (isLocalFileDocument || collabSynced);
851
+ canEdit &&
852
+ !bodyHydrationPending &&
853
+ !localSourceMissing &&
854
+ (!isLocalFileDocument || localSourceAccess === "available") &&
855
+ (isLocalFileDocument || collabSynced);
781
856
  // Bind an editor's stable Y.Doc on its first mount, even while the initial
782
857
  // state is loading. Editability and the reconcile hook share the exact
783
858
  // `collabSynced` boundary; "not loading" can precede persisted Y.Doc
@@ -810,6 +885,10 @@ function DocumentEditorBody({
810
885
  saveTimeoutRef.current = null;
811
886
  pendingDocumentSaveRef.current = null;
812
887
  }
888
+ pendingLocalSourceWriteRef.current = null;
889
+ setLocalSourceMissing(false);
890
+ setLocalSourceAccess("checking");
891
+ setLocalFileSyncRevision(0);
813
892
  }
814
893
  if (!isInitializedRef.current) {
815
894
  setLocalTitle(document.title);
@@ -951,11 +1030,69 @@ function DocumentEditorBody({
951
1030
  };
952
1031
 
953
1032
  if (isLinkedLocalSource) {
954
- const result = await writeDocumentToLinkedLocalSource(
955
- fileFirstDocument,
956
- localSource,
1033
+ let expectedLocalSourceRevision = localSourceRevisionForSave(
1034
+ options.expectedLocalSourceRevision,
1035
+ localSourceRevisionRef.current,
957
1036
  );
1037
+ if (!expectedLocalSourceRevision) {
1038
+ const baseline = await readDocumentFromLinkedLocalSource(
1039
+ document,
1040
+ localSource,
1041
+ );
1042
+ if (!baseline.ok) throw new Error(baseline.error);
1043
+ if (
1044
+ baseline.revision &&
1045
+ (baseline.document.content !==
1046
+ lastSavedContentRef.current.content ||
1047
+ baseline.document.title !== lastSavedTitleRef.current.title)
1048
+ ) {
1049
+ setLocalSourceConflict({
1050
+ diskDocument: baseline.document,
1051
+ diskRevision: baseline.revision,
1052
+ unsavedText: localContentRef.current,
1053
+ });
1054
+ throw new Error(
1055
+ "The file changed on disk before this edit could be saved.",
1056
+ );
1057
+ }
1058
+ expectedLocalSourceRevision = baseline.revision;
1059
+ localSourceRevisionRef.current = baseline.revision;
1060
+ }
1061
+ const pendingLocalWrite = {
1062
+ title: fileFirstDocument.title,
1063
+ content: fileFirstDocument.content,
1064
+ };
1065
+ pendingLocalSourceWriteRef.current = pendingLocalWrite;
1066
+ let result;
1067
+ try {
1068
+ result = await writeDocumentToLinkedLocalSource(
1069
+ fileFirstDocument,
1070
+ localSource,
1071
+ { expectedRevision: expectedLocalSourceRevision },
1072
+ );
1073
+ } catch (error) {
1074
+ if (pendingLocalSourceWriteRef.current === pendingLocalWrite) {
1075
+ pendingLocalSourceWriteRef.current = null;
1076
+ }
1077
+ throw error;
1078
+ }
958
1079
  if (!result.ok) {
1080
+ if (pendingLocalSourceWriteRef.current === pendingLocalWrite) {
1081
+ pendingLocalSourceWriteRef.current = null;
1082
+ }
1083
+ if (result.conflict) {
1084
+ const latest = await readDocumentFromLinkedLocalSource(
1085
+ fileFirstDocument,
1086
+ localSource,
1087
+ );
1088
+ if (latest.ok) {
1089
+ setLocalSourceConflict({
1090
+ diskDocument: latest.document,
1091
+ diskRevision: latest.revision,
1092
+ unsavedText: localContentRef.current,
1093
+ });
1094
+ }
1095
+ }
959
1096
  if (!localSourceWriteErrorShownRef.current) {
960
1097
  toast.error(t("editor.couldNotSaveLocalFile"), {
961
1098
  description: result.error,
@@ -964,6 +1101,19 @@ function DocumentEditorBody({
964
1101
  }
965
1102
  throw new Error(result.error);
966
1103
  }
1104
+ localSourceRevisionRef.current = result.revision;
1105
+ lastSavedTitleRef.current = {
1106
+ ...lastSavedTitleRef.current,
1107
+ title: fileFirstDocument.title,
1108
+ };
1109
+ lastSavedContentRef.current = {
1110
+ ...lastSavedContentRef.current,
1111
+ content: fileFirstDocument.content,
1112
+ };
1113
+ if (pendingLocalSourceWriteRef.current === pendingLocalWrite) {
1114
+ pendingLocalSourceWriteRef.current = null;
1115
+ }
1116
+ setLocalSourceConflict(null);
967
1117
  localSourceWriteErrorShownRef.current = false;
968
1118
  setLocalContentUpdatedAt(nextSavedAt);
969
1119
  }
@@ -1019,6 +1169,91 @@ function DocumentEditorBody({
1019
1169
  const persistDocumentUpdatesRef = useRef(persistDocumentUpdates);
1020
1170
  persistDocumentUpdatesRef.current = persistDocumentUpdates;
1021
1171
 
1172
+ useEffect(() => {
1173
+ if (!isLinkedLocalSourceDocument) return;
1174
+ let active = true;
1175
+
1176
+ const adoptDisk = async () => {
1177
+ const result = await readDocumentFromLinkedLocalSource(document);
1178
+ if (!active) return;
1179
+ if (!result.ok) {
1180
+ setLocalSourceAccess("unavailable");
1181
+ if (result.error.includes("was not found")) {
1182
+ if (saveTimeoutRef.current) {
1183
+ clearTimeout(saveTimeoutRef.current);
1184
+ saveTimeoutRef.current = null;
1185
+ pendingDocumentSaveRef.current = null;
1186
+ }
1187
+ pendingLocalSourceWriteRef.current = null;
1188
+ setLocalSourceMissing(true);
1189
+ }
1190
+ return;
1191
+ }
1192
+ setLocalSourceAccess("available");
1193
+ setLocalSourceMissing(false);
1194
+ const diskContent = result.document.content;
1195
+ const disposition = classifyLocalSourceRead({
1196
+ diskTitle: result.document.title,
1197
+ diskContent,
1198
+ localContent: localContentRef.current,
1199
+ lastSavedTitle: lastSavedTitleRef.current.title,
1200
+ lastSavedContent: lastSavedContentRef.current.content,
1201
+ pendingWrite: pendingLocalSourceWriteRef.current,
1202
+ hasPendingSave: pendingDocumentSaveRef.current !== null,
1203
+ });
1204
+ if (disposition === "pending-self-write") {
1205
+ localSourceRevisionRef.current = result.revision;
1206
+ return;
1207
+ }
1208
+ if (disposition === "conflict") {
1209
+ setLocalSourceConflict({
1210
+ diskDocument: result.document,
1211
+ diskRevision: result.revision,
1212
+ unsavedText: localContentRef.current,
1213
+ });
1214
+ return;
1215
+ }
1216
+ localSourceRevisionRef.current = result.revision;
1217
+ if (disposition === "unchanged") return;
1218
+ if (saveTimeoutRef.current) {
1219
+ clearTimeout(saveTimeoutRef.current);
1220
+ saveTimeoutRef.current = null;
1221
+ pendingDocumentSaveRef.current = null;
1222
+ }
1223
+ localTitleRef.current = result.document.title;
1224
+ localContentRef.current = diskContent;
1225
+ setLocalTitle(result.document.title);
1226
+ setLocalContent(diskContent);
1227
+ setLocalContentUpdatedAt(result.updatedAt);
1228
+ lastSavedTitleRef.current = {
1229
+ title: result.document.title,
1230
+ updatedAt: result.updatedAt,
1231
+ };
1232
+ lastSavedContentRef.current = {
1233
+ content: diskContent,
1234
+ updatedAt: result.updatedAt,
1235
+ };
1236
+ setLocalFileSyncRevision((revision) => revision + 1);
1237
+ setLocalSourceConflict(null);
1238
+ };
1239
+
1240
+ void adoptDisk();
1241
+ let stop: (() => void) | undefined;
1242
+ void watchLinkedLocalSource(document.source, () => void adoptDisk()).then(
1243
+ (result) => {
1244
+ if (!active) {
1245
+ if (result.ok) result.unsubscribe();
1246
+ return;
1247
+ }
1248
+ if (result.ok) stop = result.unsubscribe;
1249
+ },
1250
+ );
1251
+ return () => {
1252
+ active = false;
1253
+ stop?.();
1254
+ };
1255
+ }, [document.id, document.source, isLinkedLocalSourceDocument]);
1256
+
1022
1257
  const saveDocumentImmediately = useCallback(
1023
1258
  async (
1024
1259
  title: string,
@@ -1132,6 +1367,7 @@ function DocumentEditorBody({
1132
1367
  void Promise.resolve(
1133
1368
  pending.save(pending.title, pending.content, {
1134
1369
  allowQueuedSave: true,
1370
+ expectedLocalSourceRevision: pending.expectedLocalSourceRevision,
1135
1371
  }),
1136
1372
  ).catch(handleBackgroundSaveError);
1137
1373
  },
@@ -1140,12 +1376,19 @@ function DocumentEditorBody({
1140
1376
  const debouncedSave = useCallback(
1141
1377
  (title: string, content: string) => {
1142
1378
  if (!canEditRef.current) return;
1379
+ const expectedLocalSourceRevision = isLinkedLocalSourceDocument
1380
+ ? localSourceRevisionForQueuedEdit(
1381
+ pendingDocumentSaveRef.current?.expectedLocalSourceRevision,
1382
+ localSourceRevisionRef.current,
1383
+ )
1384
+ : undefined;
1143
1385
  if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
1144
1386
  const pending: PendingDocumentSave = {
1145
1387
  title,
1146
1388
  content,
1147
1389
  save: queueDocumentSave,
1148
1390
  canEditWhenQueued: canEditRef.current,
1391
+ expectedLocalSourceRevision,
1149
1392
  timeout: setTimeout(() => {
1150
1393
  if (pendingDocumentSaveRef.current === pending) {
1151
1394
  pendingDocumentSaveRef.current = null;
@@ -1157,7 +1400,7 @@ function DocumentEditorBody({
1157
1400
  pendingDocumentSaveRef.current = pending;
1158
1401
  saveTimeoutRef.current = pending.timeout;
1159
1402
  },
1160
- [flushPendingDocumentSave, queueDocumentSave],
1403
+ [flushPendingDocumentSave, isLinkedLocalSourceDocument, queueDocumentSave],
1161
1404
  );
1162
1405
 
1163
1406
  useEffect(() => {
@@ -1437,6 +1680,7 @@ function DocumentEditorBody({
1437
1680
  const handleContentChange = useCallback(
1438
1681
  (newContent: string) => {
1439
1682
  if (!editorCanEdit) return;
1683
+ localContentRef.current = newContent;
1440
1684
  setLocalContent(newContent);
1441
1685
  debouncedSave(localTitleRef.current, newContent);
1442
1686
  },
@@ -1459,6 +1703,32 @@ function DocumentEditorBody({
1459
1703
  [editorCanEdit, queueDocumentSave],
1460
1704
  );
1461
1705
 
1706
+ const useDiskVersion = useCallback(() => {
1707
+ if (!localSourceConflict) return;
1708
+ const next = localSourceConflict.diskDocument;
1709
+ if (saveTimeoutRef.current) {
1710
+ clearTimeout(saveTimeoutRef.current);
1711
+ saveTimeoutRef.current = null;
1712
+ pendingDocumentSaveRef.current = null;
1713
+ }
1714
+ localSourceRevisionRef.current = localSourceConflict.diskRevision;
1715
+ localTitleRef.current = next.title;
1716
+ localContentRef.current = next.content;
1717
+ setLocalTitle(next.title);
1718
+ setLocalContent(next.content);
1719
+ setLocalContentUpdatedAt(next.updatedAt ?? new Date().toISOString());
1720
+ lastSavedTitleRef.current = {
1721
+ title: next.title,
1722
+ updatedAt: next.updatedAt ?? null,
1723
+ };
1724
+ lastSavedContentRef.current = {
1725
+ content: next.content,
1726
+ updatedAt: next.updatedAt ?? null,
1727
+ };
1728
+ setLocalFileSyncRevision((revision) => revision + 1);
1729
+ setLocalSourceConflict(null);
1730
+ }, [localSourceConflict]);
1731
+
1462
1732
  // Comments state — pending comment from text selection
1463
1733
  const [pendingComment, setPendingComment] = useState<{
1464
1734
  quotedText: string;
@@ -1767,7 +2037,7 @@ function DocumentEditorBody({
1767
2037
  agentPresent={agentPresent}
1768
2038
  agentActive={agentActive}
1769
2039
  currentUserEmail={session?.email}
1770
- canEdit={canEdit}
2040
+ canEdit={editorCanEdit}
1771
2041
  hideFromSearch={document.hideFromSearch}
1772
2042
  source={document.source}
1773
2043
  canDelete={canDelete}
@@ -1779,12 +2049,76 @@ function DocumentEditorBody({
1779
2049
  onUtilityPanelChange={handleUtilityPanelChange}
1780
2050
  showCommentsControl={canComment && !isLocalFileDocument}
1781
2051
  onOpenBreadcrumbItem={handleOpenToolbarBreadcrumb}
2052
+ canUndo={editorHistoryState.canUndo}
2053
+ canRedo={editorHistoryState.canRedo}
2054
+ onUndo={() => editorHistoryControllerRef.current?.undo()}
2055
+ onRedo={() => editorHistoryControllerRef.current?.redo()}
1782
2056
  />
1783
2057
 
1784
2058
  {!isLocalFileDocument ? (
1785
2059
  <NotionConflictBanner documentId={documentId} canEdit={canEdit} />
1786
2060
  ) : null}
1787
2061
 
2062
+ {localSourceConflict ? (
2063
+ <div
2064
+ className="flex flex-wrap items-center gap-2 border-b bg-muted/40 px-4 py-2 text-sm"
2065
+ role="alert"
2066
+ data-local-source-conflict
2067
+ >
2068
+ <span className="me-auto">
2069
+ {t("editor.localFileChangedWithUnsavedEdits")}
2070
+ </span>
2071
+ <Button
2072
+ type="button"
2073
+ size="sm"
2074
+ variant="ghost"
2075
+ onClick={() => {
2076
+ void writeClipboardText(localSourceConflict.unsavedText).then(
2077
+ (copied) => {
2078
+ if (copied) {
2079
+ toast.success(t("editor.unsavedTextCopied"));
2080
+ } else {
2081
+ toast.error(
2082
+ t("editor.toolbar.clipboardAccessUnavailable"),
2083
+ );
2084
+ }
2085
+ },
2086
+ );
2087
+ }}
2088
+ >
2089
+ {t("editor.copyUnsavedText")}
2090
+ </Button>
2091
+ <Button type="button" size="sm" onClick={useDiskVersion}>
2092
+ {t("editor.useDiskVersion")}
2093
+ </Button>
2094
+ </div>
2095
+ ) : null}
2096
+
2097
+ {localSourceMissing ? (
2098
+ <div
2099
+ className="border-b bg-muted/40 px-4 py-2 text-sm"
2100
+ role="alert"
2101
+ data-local-source-missing
2102
+ >
2103
+ {t("empty.documentNotFound")}
2104
+ </div>
2105
+ ) : null}
2106
+
2107
+ {isLocalFileDocument && localSourceAccess === "unavailable" ? (
2108
+ <div
2109
+ className="border-b bg-muted/20 px-4 py-1.5 text-xs text-muted-foreground"
2110
+ role="status"
2111
+ data-local-source-read-only
2112
+ >
2113
+ {t("editor.localFileReadOnlySnapshot", {
2114
+ device: "Agent Native Desktop",
2115
+ date: new Date(
2116
+ document.source?.updatedAt ?? document.updatedAt,
2117
+ ).toLocaleString(),
2118
+ })}
2119
+ </div>
2120
+ ) : null}
2121
+
1788
2122
  <div
1789
2123
  ref={scrollContainerRef}
1790
2124
  className="flex-1 min-h-0 min-w-0 overflow-auto flex flex-col"
@@ -1976,7 +2310,15 @@ function DocumentEditorBody({
1976
2310
  // fields.
1977
2311
  const primaryEditor = (
1978
2312
  <VisualEditor
1979
- key={`${documentId}:${collabEditorEnabled && ydoc ? "live-ready" : canEdit && !isLocalFileDocument ? "live-pending" : `snapshot:${document.updatedAt}`}`}
2313
+ key={visualEditorInstanceKey({
2314
+ documentId,
2315
+ documentUpdatedAt: document.updatedAt,
2316
+ isLocalFileDocument,
2317
+ canEdit,
2318
+ collabEditorEnabled,
2319
+ hasYDoc: Boolean(ydoc),
2320
+ localFileSyncRevision,
2321
+ })}
1980
2322
  documentId={documentId}
1981
2323
  content={
1982
2324
  isLocalFileDocument
@@ -2014,6 +2356,10 @@ function DocumentEditorBody({
2014
2356
  notionPageLinks={notionPageLinks}
2015
2357
  onOpenNotionPageLink={handleOpenNotionPageLink}
2016
2358
  notionPageId={document.notionPageId}
2359
+ onHistoryControllerChange={
2360
+ handleHistoryControllerChange
2361
+ }
2362
+ onHistoryStateChange={handleHistoryStateChange}
2017
2363
  />
2018
2364
  );
2019
2365
 
@@ -11,6 +11,8 @@ import type { DocumentSourceInfo } from "@shared/api";
11
11
  import {
12
12
  IconArrowBarDown,
13
13
  IconArrowBarUp,
14
+ IconArrowBackUp,
15
+ IconArrowForwardUp,
14
16
  IconAlertTriangle,
15
17
  IconCheck,
16
18
  IconCopy,
@@ -472,6 +474,10 @@ interface DocumentToolbarProps {
472
474
  onUtilityPanelChange: (panel: "info" | "comments" | null) => void;
473
475
  showCommentsControl?: boolean;
474
476
  onOpenBreadcrumbItem?: (id: string) => void;
477
+ canUndo?: boolean;
478
+ canRedo?: boolean;
479
+ onUndo?: () => void;
480
+ onRedo?: () => void;
475
481
  }
476
482
 
477
483
  export function DocumentToolbar({
@@ -494,6 +500,10 @@ export function DocumentToolbar({
494
500
  onUtilityPanelChange,
495
501
  showCommentsControl = true,
496
502
  onOpenBreadcrumbItem,
503
+ canUndo = false,
504
+ canRedo = false,
505
+ onUndo,
506
+ onRedo,
497
507
  }: DocumentToolbarProps) {
498
508
  const t = useT();
499
509
  const navigate = useNavigate();
@@ -971,6 +981,17 @@ export function DocumentToolbar({
971
981
  </TooltipContent>
972
982
  </Tooltip>
973
983
  <DropdownMenuContent align="end" className="w-60">
984
+ <DropdownMenuGroup>
985
+ <DropdownMenuItem disabled={!canUndo} onSelect={onUndo}>
986
+ <IconArrowBackUp className="me-2 h-4 w-4" />
987
+ {t("editor.toolbar.undo")}
988
+ </DropdownMenuItem>
989
+ <DropdownMenuItem disabled={!canRedo} onSelect={onRedo}>
990
+ <IconArrowForwardUp className="me-2 h-4 w-4" />
991
+ {t("editor.toolbar.redo")}
992
+ </DropdownMenuItem>
993
+ </DropdownMenuGroup>
994
+ <DropdownMenuSeparator />
974
995
  <DropdownMenuGroup>
975
996
  <DropdownMenuItem onSelect={() => void handleCopyPageLink()}>
976
997
  <IconLink className="me-2 h-4 w-4" />
@@ -1018,7 +1039,7 @@ export function DocumentToolbar({
1018
1039
  <span className="truncate">{source?.path}</span>
1019
1040
  </DropdownMenuItem>
1020
1041
  <DropdownMenuItem
1021
- disabled={revealLocalSource.isPending}
1042
+ disabled={!canEdit || revealLocalSource.isPending}
1022
1043
  onSelect={() => void handleRevealLocalPath()}
1023
1044
  >
1024
1045
  <IconFolderOpen className="me-2 h-4 w-4" />
@@ -1029,6 +1050,7 @@ export function DocumentToolbar({
1029
1050
  {t("editor.toolbar.copyRelativePath")}
1030
1051
  </DropdownMenuItem>
1031
1052
  <DropdownMenuItem
1053
+ disabled={!canEdit}
1032
1054
  onSelect={() => void handleCopyLocalAbsolutePath()}
1033
1055
  >
1034
1056
  <IconCopy className="me-2 h-4 w-4" />