@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +22 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-db-sync.ts +23 -21
- package/corpus/core/src/deploy/build.ts +1 -0
- package/corpus/core/src/server/core-routes-plugin.ts +3 -0
- package/corpus/core/src/server/gateway-access-check.ts +67 -0
- package/corpus/core/src/server/poll.ts +295 -9
- package/corpus/core/src/server/short-lived-token.ts +122 -0
- package/corpus/templates/content/actions/update-document.ts +145 -91
- package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
- package/corpus/templates/content/app/i18n-data.ts +15 -0
- package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
- package/corpus/templates/design/actions/update-file.ts +85 -18
- package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
- package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
- package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
- package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +27 -22
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +1 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/gateway-access-check.d.ts +12 -0
- package/dist/server/gateway-access-check.d.ts.map +1 -0
- package/dist/server/gateway-access-check.js +49 -0
- package/dist/server/gateway-access-check.js.map +1 -0
- package/dist/server/poll.d.ts +55 -1
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +254 -7
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/short-lived-token.d.ts +28 -0
- package/dist/server/short-lived-token.d.ts.map +1 -1
- package/dist/server/short-lived-token.js +78 -0
- package/dist/server/short-lived-token.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +2 -2
- package/src/client/use-db-sync.ts +23 -21
- package/src/deploy/build.ts +1 -0
- package/src/server/core-routes-plugin.ts +3 -0
- package/src/server/gateway-access-check.ts +67 -0
- package/src/server/poll.ts +295 -9
- 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
|
-
//
|
|
648
|
-
//
|
|
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
|
|
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={() =>
|
|
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
|
-
|
|
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={
|
|
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}:${
|
|
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
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
163
|
+
const items = usableData
|
|
158
164
|
? applyDatabaseView(
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
176
|
+
usableData?.properties ?? [],
|
|
171
177
|
activeView.groupByPropertyId,
|
|
172
178
|
),
|
|
173
179
|
activeView.hideEmptyGroups === true,
|
|
174
180
|
);
|
|
175
|
-
const hasFilesHierarchy =
|
|
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
|
|
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(
|
|
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 },
|
|
@@ -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
|
-
//
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
309
|
-
//
|
|
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
|
</>
|