@agent-native/core 0.120.3 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +22 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-db-sync.ts +23 -21
  5. package/corpus/core/src/deploy/build.ts +1 -0
  6. package/corpus/core/src/server/core-routes-plugin.ts +3 -0
  7. package/corpus/core/src/server/gateway-access-check.ts +67 -0
  8. package/corpus/core/src/server/poll.ts +295 -9
  9. package/corpus/core/src/server/short-lived-token.ts +122 -0
  10. package/corpus/templates/content/actions/update-document.ts +145 -91
  11. package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
  12. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
  13. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
  14. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
  15. package/corpus/templates/content/app/i18n-data.ts +15 -0
  16. package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
  17. package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
  18. package/corpus/templates/design/actions/update-file.ts +85 -18
  19. package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
  20. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
  21. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
  22. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
  23. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
  24. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  25. package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
  26. package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
  27. package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
  28. package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
  29. package/corpus/toolkit/CHANGELOG.md +6 -0
  30. package/corpus/toolkit/package.json +1 -1
  31. package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
  32. package/dist/client/use-db-sync.d.ts.map +1 -1
  33. package/dist/client/use-db-sync.js +27 -22
  34. package/dist/client/use-db-sync.js.map +1 -1
  35. package/dist/collab/awareness.d.ts +2 -2
  36. package/dist/collab/awareness.d.ts.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +1 -0
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  41. package/dist/notifications/routes.d.ts +1 -1
  42. package/dist/observability/routes.d.ts +3 -3
  43. package/dist/progress/routes.d.ts +1 -1
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  45. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  46. package/dist/resources/handlers.d.ts +1 -1
  47. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  48. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  49. package/dist/server/core-routes-plugin.js +3 -0
  50. package/dist/server/core-routes-plugin.js.map +1 -1
  51. package/dist/server/gateway-access-check.d.ts +12 -0
  52. package/dist/server/gateway-access-check.d.ts.map +1 -0
  53. package/dist/server/gateway-access-check.js +49 -0
  54. package/dist/server/gateway-access-check.js.map +1 -0
  55. package/dist/server/poll.d.ts +55 -1
  56. package/dist/server/poll.d.ts.map +1 -1
  57. package/dist/server/poll.js +254 -7
  58. package/dist/server/poll.js.map +1 -1
  59. package/dist/server/realtime-token.d.ts +1 -1
  60. package/dist/server/short-lived-token.d.ts +28 -0
  61. package/dist/server/short-lived-token.d.ts.map +1 -1
  62. package/dist/server/short-lived-token.js +78 -0
  63. package/dist/server/short-lived-token.js.map +1 -1
  64. package/dist/server/transcribe-voice.d.ts +1 -1
  65. package/package.json +2 -2
  66. package/src/client/use-db-sync.ts +23 -21
  67. package/src/deploy/build.ts +1 -0
  68. package/src/server/core-routes-plugin.ts +3 -0
  69. package/src/server/gateway-access-check.ts +67 -0
  70. package/src/server/poll.ts +295 -9
  71. package/src/server/short-lived-token.ts +122 -0
@@ -644,16 +644,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
644
644
  }
645
645
  : undefined;
646
646
 
647
- // Live collaboration for everyone who can open the doc — editors and viewers
648
- // alike. Viewers join the shared Y.Doc read-only: they see live keystrokes,
649
- // cursors, and presence (Google-Docs style) instead of a lagging SQL snapshot.
650
- // The server enforces the split — collab READ routes (state / awareness GET /
651
- // users) require viewer access, WRITE routes (update) require editor — so a
652
- // viewer's client can subscribe but never push. The editor stays non-editable
653
- // for viewers (see `editable={canEdit}` below), and VisualEditor additionally
654
- // neutralizes every local Y.Doc mutation for viewers (no seed, no reconcile
655
- // apply) so a read-only client can never originate a rejected `/update` POST.
656
- // Local-file documents are still excluded (they have no SQL-backed collab doc).
647
+ // All SQL-backed readers subscribe for presence. Only editors bind the body
648
+ // to Yjs; viewers render canonical SQL so missing collab state cannot hide it.
657
649
  const collabEnabled = !isLocalFileDocument;
658
650
  const {
659
651
  ydoc,
@@ -671,6 +663,12 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
671
663
  const bodyHydrationPending = documentBodyHydrationIsPending(document);
672
664
  const editorCanEdit =
673
665
  canEdit && !bodyHydrationPending && (isLocalFileDocument || !collabLoading);
666
+ // Bind an editor's stable Y.Doc on its first mount, even while the initial
667
+ // state is loading. Editability remains gated by `editorCanEdit`, and the
668
+ // reconcile hook remains gated by `collabSynced`; keeping the Y.Doc binding
669
+ // stable avoids a snapshot -> collab remount that can seed the same SQL body
670
+ // beside freshly projected persisted CRDT content.
671
+ const collabEditorEnabled = collabEnabled && canEdit && !bodyHydrationPending;
674
672
  canEditRef.current = editorCanEdit;
675
673
 
676
674
  // Viewers intentionally join awareness so they receive live cursors, but
@@ -900,6 +898,12 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
900
898
  },
901
899
  [document, documentId, queryClient, updateDocument],
902
900
  );
901
+ // The document query can refresh its object identity without changing the
902
+ // flush request itself. Keep the latest save function behind a ref so those
903
+ // routine refreshes do not restart the one-shot flush reader and flood the
904
+ // browser with duplicate application-state requests.
905
+ const persistDocumentUpdatesRef = useRef(persistDocumentUpdates);
906
+ persistDocumentUpdatesRef.current = persistDocumentUpdates;
903
907
 
904
908
  const saveDocumentImmediately = useCallback(
905
909
  async (
@@ -1216,7 +1220,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1216
1220
  }
1217
1221
  try {
1218
1222
  if (Object.keys(updates).length > 0) {
1219
- const saved = await persistDocumentUpdates(updates);
1223
+ const saved = await persistDocumentUpdatesRef.current(updates);
1220
1224
  if (isDocumentUpdateConflict(saved)) {
1221
1225
  // Do not acknowledge a CAS loss as a successful flush. The
1222
1226
  // requester must stop instead of pushing/replacing stale SQL.
@@ -1290,7 +1294,6 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1290
1294
  flushRequestKey,
1291
1295
  flushRequestWake,
1292
1296
  isLocalFileDocument,
1293
- persistDocumentUpdates,
1294
1297
  t,
1295
1298
  ]);
1296
1299
 
@@ -1372,6 +1375,24 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1372
1375
  setHoveredThreadId(null);
1373
1376
  }, []);
1374
1377
 
1378
+ const activateCommentThread = useCallback((threadId: string) => {
1379
+ setPendingComment(null);
1380
+ setHoveredThreadId(null);
1381
+ setSelectedThreadId(threadId);
1382
+ setUtilityPanel("comments");
1383
+ }, []);
1384
+
1385
+ const handleUtilityPanelChange = useCallback(
1386
+ (nextPanel: DocumentUtilityPanel) => {
1387
+ setUtilityPanel(nextPanel);
1388
+ if (nextPanel !== "comments") {
1389
+ setPendingComment(null);
1390
+ clearCommentFocus();
1391
+ }
1392
+ },
1393
+ [clearCommentFocus],
1394
+ );
1395
+
1375
1396
  useEffect(() => {
1376
1397
  setPendingComment(null);
1377
1398
  setUtilityPanel(null);
@@ -1512,9 +1533,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1512
1533
  scrollContainerRef={scrollContainerRef}
1513
1534
  activeThreadId={activeThreadId}
1514
1535
  selectedThreadId={selectedThreadId}
1536
+ onActivateThread={activateCommentThread}
1515
1537
  onSelectedThreadChange={setSelectedThreadId}
1516
1538
  onHoveredThreadChange={setHoveredThreadId}
1517
1539
  currentUserEmail={session?.email}
1540
+ alignToAnchors={hasUtilityRailSpace}
1518
1541
  forceVisible
1519
1542
  />
1520
1543
  );
@@ -1566,7 +1589,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1566
1589
  type="button"
1567
1590
  className="ms-auto flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1568
1591
  aria-label={t("editor.toolbar.closeUtilityPanel")}
1569
- onClick={() => setUtilityPanel(null)}
1592
+ onClick={() => handleUtilityPanelChange(null)}
1570
1593
  >
1571
1594
  <IconX size={16} />
1572
1595
  </button>
@@ -1594,7 +1617,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1594
1617
  <div
1595
1618
  className="relative flex min-h-0 min-w-0 flex-1"
1596
1619
  data-document-print-root
1597
- onPointerDownCapture={(event) => {
1620
+ onClickCapture={(event) => {
1598
1621
  const target = event.target as HTMLElement | null;
1599
1622
  if (
1600
1623
  target?.closest("[data-comments-sidebar], [data-comment-thread]")
@@ -1626,7 +1649,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1626
1649
  }
1627
1650
  onDelete={handleDeleteDocument}
1628
1651
  utilityPanel={utilityPanel}
1629
- onUtilityPanelChange={setUtilityPanel}
1652
+ onUtilityPanelChange={handleUtilityPanelChange}
1630
1653
  showCommentsControl={editorCanEdit && !isLocalFileDocument}
1631
1654
  onOpenBreadcrumbItem={handleOpenToolbarBreadcrumb}
1632
1655
  />
@@ -1817,7 +1840,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1817
1840
  // fields.
1818
1841
  const primaryEditor = (
1819
1842
  <VisualEditor
1820
- key={`${documentId}:${editorCanEdit && !isLocalFileDocument ? "live" : "snapshot"}`}
1843
+ key={`${documentId}:${collabEditorEnabled && ydoc ? "live-ready" : canEdit && !isLocalFileDocument ? "live-pending" : `snapshot:${document.updatedAt}`}`}
1821
1844
  documentId={documentId}
1822
1845
  content={
1823
1846
  isLocalFileDocument
@@ -1831,13 +1854,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1831
1854
  }
1832
1855
  onChange={handleContentChange}
1833
1856
  onSaveContent={handleContentSaveNow}
1834
- // Bind the shared Y.Doc/awareness for viewers too — the
1835
- // editor is non-editable for them and VisualEditor blocks
1836
- // any local Y.Doc mutation, so they get live edits +
1837
- // cursors without ever writing. Excludes local-file docs.
1838
- ydoc={collabEnabled ? ydoc : null}
1839
- collabSynced={collabEnabled ? collabSynced : true}
1840
- awareness={collabEnabled ? awareness : null}
1857
+ ydoc={collabEditorEnabled ? ydoc : null}
1858
+ collabSynced={
1859
+ collabEditorEnabled ? collabSynced : true
1860
+ }
1861
+ awareness={collabEditorEnabled ? awareness : null}
1841
1862
  user={currentUser}
1842
1863
  editable={editorCanEdit}
1843
1864
  localFileMode={isLocalFileDocument}
@@ -1854,7 +1875,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1854
1875
  pendingHighlight={pendingComment?.range ?? null}
1855
1876
  onActivateThread={
1856
1877
  editorCanEdit && !isLocalFileDocument
1857
- ? setSelectedThreadId
1878
+ ? activateCommentThread
1858
1879
  : undefined
1859
1880
  }
1860
1881
  onJoinTitle={joinFirstBodyBlockToTitle}
@@ -1883,6 +1904,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1883
1904
  })()}
1884
1905
  {!bodyHydrationPending &&
1885
1906
  !isLocalFileDocument &&
1907
+ canEdit &&
1886
1908
  collabLoading ? (
1887
1909
  <div
1888
1910
  className="mt-4 inline-flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
@@ -1910,20 +1932,21 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1910
1932
  open={utilityPanel !== null}
1911
1933
  onOpenChange={(open) => {
1912
1934
  if (!open) {
1913
- setUtilityPanel(null);
1914
- setPendingComment(null);
1935
+ handleUtilityPanelChange(null);
1915
1936
  }
1916
1937
  }}
1917
1938
  >
1918
1939
  <SheetContent
1919
1940
  side="right"
1920
- className="w-[85vw] max-w-sm p-0"
1941
+ className="flex min-h-0 w-[85vw] max-w-sm flex-col overflow-hidden p-0"
1921
1942
  aria-describedby={undefined}
1922
1943
  >
1923
1944
  <SheetHeader className="sr-only">
1924
1945
  <SheetTitle>{utilityPanelTitle}</SheetTitle>
1925
1946
  </SheetHeader>
1926
- {utilityPanelContent}
1947
+ <div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto">
1948
+ {utilityPanelContent}
1949
+ </div>
1927
1950
  </SheetContent>
1928
1951
  </Sheet>
1929
1952
  ) : null}
@@ -136,8 +136,14 @@ export function ContentFilesSidebarView({
136
136
  | "scroll"
137
137
  >;
138
138
  }) {
139
+ const usableData =
140
+ data?.database &&
141
+ Array.isArray(data.items) &&
142
+ Array.isArray(data.properties)
143
+ ? data
144
+ : undefined;
139
145
  const viewConfig = applyPersonalSidebarViewOverrides(
140
- data?.database.viewConfig ?? defaultDatabaseViewConfig(),
146
+ usableData?.database.viewConfig ?? defaultDatabaseViewConfig(),
141
147
  overrides,
142
148
  );
143
149
  const [selectedViewId, setSelectedViewId] = useState(
@@ -154,10 +160,10 @@ export function ContentFilesSidebarView({
154
160
  useEffect(() => {
155
161
  setConstraintsCleared(false);
156
162
  }, [activeFilterKey, activeView.id]);
157
- const items = data
163
+ const items = usableData
158
164
  ? applyDatabaseView(
159
- data.items,
160
- data.properties,
165
+ usableData.items,
166
+ usableData.properties,
161
167
  "",
162
168
  constraintsCleared ? [] : activeView.filters,
163
169
  activeView.sorts,
@@ -167,16 +173,18 @@ export function ContentFilesSidebarView({
167
173
  const groups = databaseVisibleGroups(
168
174
  databaseViewItemGroups(
169
175
  items,
170
- data?.properties ?? [],
176
+ usableData?.properties ?? [],
171
177
  activeView.groupByPropertyId,
172
178
  ),
173
179
  activeView.hideEmptyGroups === true,
174
180
  );
175
- const hasFilesHierarchy = data?.properties.some(
181
+ const hasFilesHierarchy = usableData?.properties.some(
176
182
  (property) => property.definition.systemRole === "files_parent",
177
183
  );
178
184
  const hierarchyItems = hasFilesHierarchy ? items : undefined;
179
- const hierarchyUniverseItems = hasFilesHierarchy ? data?.items : undefined;
185
+ const hierarchyUniverseItems = hasFilesHierarchy
186
+ ? usableData?.items
187
+ : undefined;
180
188
  return (
181
189
  <div className="min-w-0">
182
190
  {viewConfig.views.length > 1 && (
@@ -204,7 +212,10 @@ export function ContentFilesSidebarView({
204
212
  {...labels}
205
213
  groups={groups}
206
214
  grouped={
207
- !!databaseViewGroupingProperty(activeView, data?.properties ?? [])
215
+ !!databaseViewGroupingProperty(
216
+ activeView,
217
+ usableData?.properties ?? [],
218
+ )
208
219
  }
209
220
  isLoading={isLoading}
210
221
  hasActiveConstraints={
@@ -3085,6 +3085,7 @@ const enUS = {
3085
3085
  askAi: "Ask AI",
3086
3086
  resolve: "Resolve",
3087
3087
  resolved: "Resolved ({{count}})",
3088
+ unanchored: "Highlight unavailable",
3088
3089
  reply: "Reply...",
3089
3090
  reopen: "Reopen",
3090
3091
  agentRegardingText: 'Regarding this text: "{{text}}"',
@@ -8761,6 +8762,19 @@ const contentReferenceMessagesByLocale = {
8761
8762
  },
8762
8763
  } satisfies Partial<Record<LocaleCode, typeof enUS.editor.reference>>;
8763
8764
 
8765
+ const commentMessagesByLocale = {
8766
+ "zh-CN": { unanchored: "高亮不可用" },
8767
+ "zh-TW": { unanchored: "無法使用醒目提示" },
8768
+ "es-ES": { unanchored: "Resaltado no disponible" },
8769
+ "fr-FR": { unanchored: "Surlignage indisponible" },
8770
+ "de-DE": { unanchored: "Hervorhebung nicht verfügbar" },
8771
+ "ja-JP": { unanchored: "ハイライトを利用できません" },
8772
+ "ko-KR": { unanchored: "강조 표시를 사용할 수 없음" },
8773
+ "pt-BR": { unanchored: "Destaque indisponível" },
8774
+ "hi-IN": { unanchored: "हाइलाइट उपलब्ध नहीं है" },
8775
+ "ar-SA": { unanchored: "التمييز غير متاح" },
8776
+ } satisfies Partial<Record<LocaleCode, Partial<typeof enUS.comments>>>;
8777
+
8764
8778
  function mergeMessages(overrides: PartialMessages): Messages {
8765
8779
  return {
8766
8780
  root: { ...enUS.root, ...overrides.root },
@@ -8862,6 +8876,7 @@ function mergeMessagesForLocale(
8862
8876
  });
8863
8877
  return {
8864
8878
  ...base,
8879
+ comments: { ...base.comments, ...commentMessagesByLocale[locale] },
8865
8880
  root: { ...base.root, ...rawLiteralOverrides.root },
8866
8881
  team: { ...base.team, ...rawLiteralOverrides.team },
8867
8882
  settings: { ...base.settings, ...rawLiteralOverrides.settings },
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-23
4
+ ---
5
+
6
+ Shared pages now keep durable content visible, and comment threads stay stable and connected while you navigate.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-24
4
+ ---
5
+
6
+ Comment drafts now stay open when saving fails, and long-lived document sessions no longer flood the browser with sync requests.
@@ -5,7 +5,7 @@ import {
5
5
  applyText,
6
6
  seedFromText,
7
7
  } from "@agent-native/core/collab";
8
- import { isPostgres } from "@agent-native/core/db";
8
+ import { getDbExec, isPostgres } from "@agent-native/core/db";
9
9
  import { accessFilter, assertAccess } from "@agent-native/core/sharing";
10
10
  import { and, eq, isNull, sql } from "drizzle-orm";
11
11
  import { z } from "zod";
@@ -16,6 +16,30 @@ import { assertDesignHtmlEditIntegrity } from "../shared/html-integrity.js";
16
16
  import { assertLockedLayersPreserved } from "../shared/locked-layers.js";
17
17
  import { sourceContentHash } from "../shared/source-workspace.js";
18
18
 
19
+ // TEMPORARY diagnostic — remove once we've read a few real conflicts in prod.
20
+ // Conflicts are now benign 409s the framework no longer error-logs; this keeps
21
+ // them visible in Netlify, and `content-conflict`'s cacheIsStale field tells us
22
+ // whether they're the multi-instance stale-cache phantom (→ worth the core
23
+ // reload fix) or a genuine concurrent writer (→ 409-rebase is already correct).
24
+ function logSaveConflictDebug(
25
+ event: string,
26
+ detail: Record<string, unknown>,
27
+ ): void {
28
+ console.warn(`[update-file:debug] ${event}`, detail);
29
+ }
30
+
31
+ // 404 via statusCode (NOT status — the action route only reads statusCode) so
32
+ // the client save-outbox treats a gone file as terminal and drops it, instead
33
+ // of looping a masked 500. Used at every missing-file guard, including the
34
+ // post-lock rereads a concurrent delete can hit.
35
+ function fileNotFound(id: string): Error & { statusCode?: number } {
36
+ const err = new Error(`File not found: ${id}`) as Error & {
37
+ statusCode?: number;
38
+ };
39
+ err.statusCode = 404;
40
+ return err;
41
+ }
42
+
19
43
  function rowsAffected(result: unknown): number | undefined {
20
44
  const candidate = result as {
21
45
  rowsAffected?: unknown;
@@ -169,16 +193,8 @@ export default defineAction({
169
193
  .limit(1);
170
194
 
171
195
  if (!file) {
172
- // Terminal (not transient): the row is gone or out of access scope, so a
173
- // retry can never succeed. Mark it 404 so the client save-outbox drops the
174
- // entry instead of retrying forever (which turns one orphaned/deleted
175
- // screen into an update-file 500 storm — see design-save-outbox
176
- // isTerminalSaveError).
177
- const notFound = new Error(`File not found: ${id}`) as Error & {
178
- status?: number;
179
- };
180
- notFound.status = 404;
181
- throw notFound;
196
+ // The row is gone or out of access scope retry can't succeed.
197
+ throw fileNotFound(id);
182
198
  }
183
199
 
184
200
  await assertAccess("design", file.designId, "editor");
@@ -232,7 +248,9 @@ export default defineAction({
232
248
  .where(eq(schema.designFiles.id, id))
233
249
  .limit(1);
234
250
  if (!persistedFile) {
235
- throw new Error(`File not found: ${id}`);
251
+ // Delete-race: the row passed the access check but is gone now.
252
+ // Same 404 as the outer guard, not a bare 500 the outbox retries.
253
+ throw fileNotFound(id);
236
254
  }
237
255
 
238
256
  const persistedContentHash = sourceContentHash(persistedFile.content);
@@ -390,9 +408,47 @@ export default defineAction({
390
408
  skippedStaleMirror = true;
391
409
  }
392
410
  } else {
393
- throw new Error(
411
+ const liveContentHash = sourceContentHash(liveContent);
412
+ // Diagnostic probe (never affects the outcome): `getText` read this
413
+ // instance's Y.Doc cache, never re-hydrated from the DB. Compare it
414
+ // to the freshly persisted text_snapshot so the log distinguishes a
415
+ // multi-instance stale cache (cacheIsStale) from a genuine writer.
416
+ let freshDbLiveHash: string | null = null;
417
+ try {
418
+ const { rows } = await getDbExec().execute({
419
+ sql: "SELECT text_snapshot FROM _collab_docs WHERE doc_id = ?",
420
+ args: [id],
421
+ });
422
+ if (rows.length > 0) {
423
+ freshDbLiveHash = sourceContentHash(
424
+ String(rows[0]?.text_snapshot ?? ""),
425
+ );
426
+ }
427
+ } catch {
428
+ // diagnostic only
429
+ }
430
+ logSaveConflictDebug("content-conflict", {
431
+ id,
432
+ caller: context?.caller,
433
+ syncCollab,
434
+ operationRevision: operationRevision ?? null,
435
+ expectedVersionHash,
436
+ sentContentHash: sourceContentHash(content),
437
+ liveContentHash,
438
+ persistedMirrorHash: persistedContentHash,
439
+ cacheIsStale:
440
+ freshDbLiveHash !== null &&
441
+ freshDbLiveHash !== liveContentHash,
442
+ freshDbMatchesExpected: freshDbLiveHash === expectedVersionHash,
443
+ });
444
+ // 409 (statusCode), not a bare 500: an expected optimistic-
445
+ // concurrency outcome the framework returns verbatim and the client
446
+ // rebases from, instead of a Sentry-captured fault + retry storm.
447
+ const conflict = new Error(
394
448
  "File changed since it was read. Re-read the file and retry.",
395
- );
449
+ ) as Error & { statusCode?: number };
450
+ conflict.statusCode = 409;
451
+ throw conflict;
396
452
  }
397
453
  }
398
454
  }
@@ -461,7 +517,9 @@ export default defineAction({
461
517
  // guarded UPDATE alone can still race. Serialize design-file renames in
462
518
  // this rare path without using SQLite's fragile async savepoint wrapper.
463
519
  await (
464
- tx as unknown as { execute: (query: unknown) => Promise<unknown> }
520
+ tx as unknown as {
521
+ execute: (query: unknown) => Promise<unknown>;
522
+ }
465
523
  ).execute(sql`LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE`);
466
524
  const [collision] = await tx
467
525
  .select({ id: schema.designFiles.id })
@@ -545,7 +603,7 @@ export default defineAction({
545
603
  .from(schema.designFiles)
546
604
  .where(eq(schema.designFiles.id, id))
547
605
  .limit(1);
548
- if (!confirmed) throw new Error(`File not found: ${id}`);
606
+ if (!confirmed) throw fileNotFound(id);
549
607
  const confirmedHash = sourceContentHash(confirmed.content);
550
608
  const exactOperationPersisted =
551
609
  confirmed.contentOperationSource === operationSource &&
@@ -622,9 +680,18 @@ export default defineAction({
622
680
  }
623
681
  return;
624
682
  }
625
- throw new Error(
683
+ logSaveConflictDebug("retry-exhausted", {
684
+ id,
685
+ caller: context?.caller,
686
+ operationSource: operationSource ?? null,
687
+ operationRevision: operationRevision ?? null,
688
+ expectedVersionHash,
689
+ });
690
+ const exhausted = new Error(
626
691
  "File changed repeatedly while it was being saved. Re-read the file and retry.",
627
- );
692
+ ) as Error & { statusCode?: number };
693
+ exhausted.statusCode = 409;
694
+ throw exhausted;
628
695
  });
629
696
 
630
697
  // Update the parent design's updatedAt timestamp. This still runs even
@@ -37,6 +37,14 @@ export interface DrainDesignSaveOutboxResult {
37
37
  * transient/conflict errors that SHOULD be retried.
38
38
  */
39
39
  dropped: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>;
40
+ /**
41
+ * Entries dropped because the server moved past their base version (a 409
42
+ * conflict). Distinct from `dropped`: the file still exists and nothing was
43
+ * lost to deletion, so the editor should rebase from the server rather than
44
+ * warn "changes discarded". Kept separate so a normal concurrent edit is
45
+ * never presented as a deleted file.
46
+ */
47
+ rebased: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>;
40
48
  }
41
49
 
42
50
  /**
@@ -58,6 +66,24 @@ export function isTerminalSaveError(error: unknown): boolean {
58
66
  );
59
67
  }
60
68
 
69
+ /**
70
+ * The server's update-file version conflict ("File changed since it was read…").
71
+ * Its frozen expectedVersionHash can never match on retry, so drop-and-rebase
72
+ * rather than loop forever. Matched by MESSAGE, not bare status 409, on purpose:
73
+ * the client-side "no known base version" / "changed elsewhere" 409 synthetics
74
+ * are intentionally retained by drainEntries, and the client-build-mismatch 409
75
+ * is a reload-then-retry.
76
+ */
77
+ export function isConflictSaveError(error: unknown): boolean {
78
+ if (!error || typeof error !== "object") return false;
79
+ const candidate = error as { code?: unknown; message?: unknown };
80
+ if (candidate.code === "client_build_mismatch") return false;
81
+ return (
82
+ typeof candidate.message === "string" &&
83
+ /changed since it was read|re-read the file/i.test(candidate.message)
84
+ );
85
+ }
86
+
61
87
  const DATABASE_NAME = "agent-native-design-save-outbox";
62
88
  const DATABASE_VERSION = 2;
63
89
  const ENTRY_STORE = "entries";
@@ -324,6 +350,7 @@ async function drainEntries(
324
350
  saved: [],
325
351
  failed: [],
326
352
  dropped: [],
353
+ rebased: [],
327
354
  };
328
355
  for (const entry of await storage.list(designId, actorScope)) {
329
356
  try {
@@ -374,6 +401,18 @@ async function drainEntries(
374
401
  `[design-save-outbox] dropped unrecoverable save for ${entry.actionName} ${entry.resourceId} (file no longer exists)`,
375
402
  );
376
403
  }
404
+ } else if (isConflictSaveError(error)) {
405
+ // Base version superseded — retry is futile and replaying the stale
406
+ // snapshot would clobber newer content. Drop into `rebased` (NOT
407
+ // `dropped`) so the editor rebases from the server instead of warning
408
+ // that the file was discarded/deleted.
409
+ await storage.deleteIfRevision(entry);
410
+ result.rebased.push({ entry, error });
411
+ if (typeof console !== "undefined") {
412
+ console.warn(
413
+ `[design-save-outbox] rebased superseded save for ${entry.actionName} ${entry.resourceId} (server content moved on)`,
414
+ );
415
+ }
377
416
  } else {
378
417
  result.failed.push({ entry, error });
379
418
  }
@@ -29,6 +29,7 @@ import {
29
29
  IconAdjustments,
30
30
  IconPencilPlus,
31
31
  IconPin,
32
+ IconLetterT,
32
33
  IconWand,
33
34
  IconUpload,
34
35
  IconSun,
@@ -123,6 +124,10 @@ interface EditorToolbarProps {
123
124
  pinMode?: boolean;
124
125
  /** Toggle comment-pin drop mode */
125
126
  onTogglePinMode?: () => void;
127
+ /** Whether the add-text-box tool is active */
128
+ textBoxMode?: boolean;
129
+ /** Toggle the add-text-box tool */
130
+ onToggleTextBoxMode?: () => void;
126
131
  /** Duplicate the current deck */
127
132
  onDuplicateDeck?: () => void;
128
133
  /** Export the deck as PDF */
@@ -247,6 +252,8 @@ export default function EditorToolbar({
247
252
  onToggleDrawMode,
248
253
  pinMode,
249
254
  onTogglePinMode,
255
+ textBoxMode,
256
+ onToggleTextBoxMode,
250
257
  onDuplicateDeck,
251
258
  onExportPdf,
252
259
  onExportPptx,
@@ -305,10 +312,10 @@ export default function EditorToolbar({
305
312
  const [themeMounted, setThemeMounted] = useState(false);
306
313
  useEffect(() => setThemeMounted(true), []);
307
314
  const isDark = themeMounted ? resolvedTheme === "dark" : false;
308
- // The four secondary tools share an "active when something is on" indicator
309
- // so the dot on the consolidated button reflects any of them.
315
+ // The secondary tools share an "active when something is on" indicator so
316
+ // the dot on the consolidated button reflects any of them.
310
317
  const anyToolActive = Boolean(
311
- animationsOpen || tweaksOpen || drawMode || pinMode,
318
+ animationsOpen || tweaksOpen || drawMode || pinMode || textBoxMode,
312
319
  );
313
320
 
314
321
  const closeAll = () => {
@@ -731,7 +738,8 @@ graph TD
731
738
  (onToggleAnimations ||
732
739
  onToggleTweaks ||
733
740
  onToggleDrawMode ||
734
- onTogglePinMode) && (
741
+ onTogglePinMode ||
742
+ onToggleTextBoxMode) && (
735
743
  <>
736
744
  <Tooltip>
737
745
  <TooltipTrigger asChild>
@@ -834,6 +842,23 @@ graph TD
834
842
  </span>
835
843
  </button>
836
844
  )}
845
+ {onToggleTextBoxMode && (
846
+ <button
847
+ onClick={() => {
848
+ onToggleTextBoxMode();
849
+ setToolsOpen(false);
850
+ }}
851
+ data-toolbar-textbox-button
852
+ className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs transition-colors ${
853
+ textBoxMode
854
+ ? "text-foreground bg-accent/50"
855
+ : "text-muted-foreground hover:text-foreground hover:bg-accent/50"
856
+ }`}
857
+ >
858
+ <IconLetterT className="w-3.5 h-3.5" />
859
+ {t("editorToolbar.addTextBox")}
860
+ </button>
861
+ )}
837
862
  </div>
838
863
  </ToolbarPopover>
839
864
  </>