@agent-native/core 0.175.2-nightly-20260826150235 → 0.175.3
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 +1 -1
- package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
- package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
- package/corpus/templates/clips/desktop/src/app.tsx +171 -51
- package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
- package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
- package/corpus/templates/clips/desktop/src/main.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
- package/corpus/templates/clips/desktop/src/styles.css +20 -308
- package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
- package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
- package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
- package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
- package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
- package/corpus/templates/design/shared/canvas-math.ts +11 -1
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
- package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
- package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
- package/dist/client/hooks/index.d.ts +1 -0
- package/dist/client/hooks/index.js +1 -0
- package/dist/client/use-pinch-zoom.js +17 -4
- package/dist/client/zoom-gesture.d.ts +74 -0
- package/dist/client/zoom-gesture.js +100 -0
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-browser.d.ts +13 -13
- package/dist/server/builder-browser.js +46 -15
- package/dist/server/builder-oauth.d.ts +12 -0
- package/dist/server/builder-oauth.js +13 -2
- package/dist/server/core-routes-plugin.js +54 -19
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/vite/client.js +2 -0
- package/docs/content/template-chat-developers.mdx +100 -13
- package/docs/content/template-chat-first-edits.mdx +5 -5
- package/docs/content/template-chat.mdx +84 -36
- package/package.json +4 -3
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
|
@@ -132,6 +132,22 @@ function isAgentPresenceUser(user: CollabUser): boolean {
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function getNextSlideId(
|
|
136
|
+
slides: Slide[],
|
|
137
|
+
activeSlideId: string,
|
|
138
|
+
key: "ArrowUp" | "ArrowDown",
|
|
139
|
+
): string | null {
|
|
140
|
+
const currentIndex = slides.findIndex((s) => s.id === activeSlideId);
|
|
141
|
+
if (currentIndex === -1) return null;
|
|
142
|
+
|
|
143
|
+
const nextIndex =
|
|
144
|
+
key === "ArrowUp"
|
|
145
|
+
? Math.max(0, currentIndex - 1)
|
|
146
|
+
: Math.min(slides.length - 1, currentIndex + 1);
|
|
147
|
+
|
|
148
|
+
return nextIndex === currentIndex ? null : (slides[nextIndex]?.id ?? null);
|
|
149
|
+
}
|
|
150
|
+
|
|
135
151
|
/** Small presence avatar circle with hover card showing name + email */
|
|
136
152
|
function PresenceAvatarTip({
|
|
137
153
|
user,
|
|
@@ -197,6 +213,7 @@ function SortableSlideThumb({
|
|
|
197
213
|
index,
|
|
198
214
|
isActive,
|
|
199
215
|
onSelect,
|
|
216
|
+
onArrowNavigate,
|
|
200
217
|
registerButtonRef,
|
|
201
218
|
presenceUsers = [],
|
|
202
219
|
aspectRatio,
|
|
@@ -219,6 +236,7 @@ function SortableSlideThumb({
|
|
|
219
236
|
index: number;
|
|
220
237
|
isActive: boolean;
|
|
221
238
|
onSelect: () => void;
|
|
239
|
+
onArrowNavigate: (key: "ArrowUp" | "ArrowDown") => void;
|
|
222
240
|
readOnly?: boolean;
|
|
223
241
|
registerButtonRef: (slideId: string, node: HTMLButtonElement | null) => void;
|
|
224
242
|
presenceUsers?: CollabUser[];
|
|
@@ -280,6 +298,14 @@ function SortableSlideThumb({
|
|
|
280
298
|
type="button"
|
|
281
299
|
{...(readOnly ? {} : attributes)}
|
|
282
300
|
{...(readOnly ? {} : listeners)}
|
|
301
|
+
onKeyDown={(event) => {
|
|
302
|
+
listeners?.onKeyDown?.(event);
|
|
303
|
+
if (event.defaultPrevented) return;
|
|
304
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
event.stopPropagation();
|
|
307
|
+
onArrowNavigate(event.key);
|
|
308
|
+
}}
|
|
283
309
|
onClick={(event) => {
|
|
284
310
|
// Safari does not focus a button on click, and the slide copy/paste
|
|
285
311
|
// and delete shortcuts are scoped to a focused thumbnail.
|
|
@@ -603,6 +629,21 @@ export default function EditorSidebar({
|
|
|
603
629
|
[describeSlideId],
|
|
604
630
|
);
|
|
605
631
|
|
|
632
|
+
const navigateToSlide = useCallback(
|
|
633
|
+
(fromSlideId: string, key: "ArrowUp" | "ArrowDown") => {
|
|
634
|
+
const nextSlideId = getNextSlideId(slides, fromSlideId, key);
|
|
635
|
+
if (!nextSlideId) return;
|
|
636
|
+
|
|
637
|
+
onSelectSlide(nextSlideId);
|
|
638
|
+
requestAnimationFrame(() => {
|
|
639
|
+
const nextButton = slideButtonRefs.current.get(nextSlideId);
|
|
640
|
+
nextButton?.focus({ preventScroll: true });
|
|
641
|
+
nextButton?.scrollIntoView({ block: "nearest" });
|
|
642
|
+
});
|
|
643
|
+
},
|
|
644
|
+
[onSelectSlide, slides],
|
|
645
|
+
);
|
|
646
|
+
|
|
606
647
|
const describeSlideIndex = describeSlideId
|
|
607
648
|
? slides.findIndex((s) => s.id === describeSlideId)
|
|
608
649
|
: -1;
|
|
@@ -629,28 +670,12 @@ export default function EditorSidebar({
|
|
|
629
670
|
return;
|
|
630
671
|
|
|
631
672
|
e.preventDefault();
|
|
632
|
-
|
|
633
|
-
if (currentIndex === -1) return;
|
|
634
|
-
|
|
635
|
-
const nextIndex =
|
|
636
|
-
e.key === "ArrowUp"
|
|
637
|
-
? Math.max(0, currentIndex - 1)
|
|
638
|
-
: Math.min(slides.length - 1, currentIndex + 1);
|
|
639
|
-
|
|
640
|
-
if (nextIndex !== currentIndex) {
|
|
641
|
-
const nextSlideId = slides[nextIndex].id;
|
|
642
|
-
onSelectSlide(nextSlideId);
|
|
643
|
-
requestAnimationFrame(() => {
|
|
644
|
-
const nextButton = slideButtonRefs.current.get(nextSlideId);
|
|
645
|
-
nextButton?.focus({ preventScroll: true });
|
|
646
|
-
nextButton?.scrollIntoView({ block: "nearest" });
|
|
647
|
-
});
|
|
648
|
-
}
|
|
673
|
+
navigateToSlide(activeSlideId, e.key as "ArrowUp" | "ArrowDown");
|
|
649
674
|
};
|
|
650
675
|
|
|
651
676
|
document.addEventListener("keydown", handleKeyDown);
|
|
652
677
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
653
|
-
}, [
|
|
678
|
+
}, [activeSlideId, navigateToSlide]);
|
|
654
679
|
|
|
655
680
|
return (
|
|
656
681
|
<div className="flex h-full min-h-0 w-48 flex-shrink-0 flex-col bg-background sm:w-52">
|
|
@@ -677,6 +702,7 @@ export default function EditorSidebar({
|
|
|
677
702
|
index={index}
|
|
678
703
|
isActive={slide.id === activeSlideId}
|
|
679
704
|
onSelect={() => onSelectSlide(slide.id)}
|
|
705
|
+
onArrowNavigate={(key) => navigateToSlide(slide.id, key)}
|
|
680
706
|
readOnly={readOnly}
|
|
681
707
|
registerButtonRef={registerSlideButton}
|
|
682
708
|
presenceUsers={slidePresence?.get(slide.id) ?? []}
|
|
@@ -59,7 +59,11 @@ import {
|
|
|
59
59
|
CanvasCommentPins,
|
|
60
60
|
MultiSelectChip,
|
|
61
61
|
} from "@/components/visual-editor";
|
|
62
|
-
import
|
|
62
|
+
import {
|
|
63
|
+
flushPendingSaves,
|
|
64
|
+
type Slide,
|
|
65
|
+
type UpdateSlideOptions,
|
|
66
|
+
} from "@/context/DeckContext";
|
|
63
67
|
import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
|
|
64
68
|
import {
|
|
65
69
|
computeCanvasFitZoom,
|
|
@@ -658,9 +662,8 @@ interface SlideEditorProps {
|
|
|
658
662
|
deckId?: string;
|
|
659
663
|
/**
|
|
660
664
|
* Called the moment the user enters contentEditable inline edit mode.
|
|
661
|
-
* The parent should
|
|
662
|
-
* reconcile path knows not to replace
|
|
663
|
-
* edit, even before a `content` update has been flushed.
|
|
665
|
+
* The parent should mark the slide as actively edited so the SSE/poll
|
|
666
|
+
* reconcile path knows not to replace it before a `content` update is queued.
|
|
664
667
|
*/
|
|
665
668
|
onInlineEditStart?: (slideId: string) => void;
|
|
666
669
|
/** Called after inline edit mode exits and its draft has been handed off.
|
|
@@ -1549,6 +1552,43 @@ export default function SlideEditor({
|
|
|
1549
1552
|
);
|
|
1550
1553
|
const previousSlideIdRef = useRef(slide.id);
|
|
1551
1554
|
|
|
1555
|
+
// Inline editing keeps its latest HTML in the DOM until blur so React does
|
|
1556
|
+
// not rerender the contentEditable on every keystroke. Hand that draft to
|
|
1557
|
+
// the keepalive queue before browser teardown.
|
|
1558
|
+
useEffect(() => {
|
|
1559
|
+
const flushInlineEditDraft = () => {
|
|
1560
|
+
// Input captures already enqueue the current draft. Keep the ref while
|
|
1561
|
+
// the editor remains mounted so the first edit after tab restore queues
|
|
1562
|
+
// against the previous snapshot instead of being mistaken for setup.
|
|
1563
|
+
if (!inlineEditDraftRef.current) return;
|
|
1564
|
+
flushPendingSaves();
|
|
1565
|
+
};
|
|
1566
|
+
const flushWhenHidden = () => {
|
|
1567
|
+
if (document.visibilityState === "hidden") flushInlineEditDraft();
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
window.addEventListener("beforeunload", flushInlineEditDraft, {
|
|
1571
|
+
capture: true,
|
|
1572
|
+
});
|
|
1573
|
+
window.addEventListener("pagehide", flushInlineEditDraft, {
|
|
1574
|
+
capture: true,
|
|
1575
|
+
});
|
|
1576
|
+
document.addEventListener("visibilitychange", flushWhenHidden, {
|
|
1577
|
+
capture: true,
|
|
1578
|
+
});
|
|
1579
|
+
return () => {
|
|
1580
|
+
window.removeEventListener("beforeunload", flushInlineEditDraft, {
|
|
1581
|
+
capture: true,
|
|
1582
|
+
});
|
|
1583
|
+
window.removeEventListener("pagehide", flushInlineEditDraft, {
|
|
1584
|
+
capture: true,
|
|
1585
|
+
});
|
|
1586
|
+
document.removeEventListener("visibilitychange", flushWhenHidden, {
|
|
1587
|
+
capture: true,
|
|
1588
|
+
});
|
|
1589
|
+
};
|
|
1590
|
+
}, []);
|
|
1591
|
+
|
|
1552
1592
|
const readCurrentSlideContentHtml = useCallback(() => {
|
|
1553
1593
|
const slideContent = containerRef.current?.querySelector(
|
|
1554
1594
|
".slide-content",
|
|
@@ -1605,13 +1645,21 @@ export default function SlideEditor({
|
|
|
1605
1645
|
(slideId = slide.id) => {
|
|
1606
1646
|
const html = readCurrentSlideContentHtml();
|
|
1607
1647
|
if (html !== null) {
|
|
1648
|
+
const next = { slideId, content: html };
|
|
1649
|
+
const previous = inlineEditDraftRef.current;
|
|
1650
|
+
const initial = inlineEditInitialContentRef.current;
|
|
1651
|
+
if (!initial || initial.slideId !== slideId) {
|
|
1652
|
+
inlineEditInitialContentRef.current = next;
|
|
1653
|
+
}
|
|
1608
1654
|
if (
|
|
1609
|
-
|
|
1610
|
-
|
|
1655
|
+
previous?.slideId === slideId &&
|
|
1656
|
+
previous.content !== next.content
|
|
1611
1657
|
) {
|
|
1612
|
-
|
|
1658
|
+
onUpdateSlideRef.current({ content: html }, slideId, {
|
|
1659
|
+
preserveLocalState: true,
|
|
1660
|
+
});
|
|
1613
1661
|
}
|
|
1614
|
-
inlineEditDraftRef.current =
|
|
1662
|
+
inlineEditDraftRef.current = next;
|
|
1615
1663
|
}
|
|
1616
1664
|
},
|
|
1617
1665
|
[readCurrentSlideContentHtml, slide.id],
|
|
@@ -1877,14 +1925,18 @@ export default function SlideEditor({
|
|
|
1877
1925
|
|
|
1878
1926
|
const html = selected ? readCurrentSlideContentHtml() : null;
|
|
1879
1927
|
const initial = inlineEditInitialContentRef.current;
|
|
1880
|
-
if (
|
|
1881
|
-
|
|
1882
|
-
shouldPersistInlineEditContent(initial, {
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1928
|
+
if (html !== null) {
|
|
1929
|
+
const current = { slideId: slide.id, content: html };
|
|
1930
|
+
if (shouldPersistInlineEditContent(initial, current)) {
|
|
1931
|
+
const latestDraft = inlineEditDraftRef.current;
|
|
1932
|
+
onUpdateSlideRef.current(
|
|
1933
|
+
{ content: html },
|
|
1934
|
+
slide.id,
|
|
1935
|
+
shouldPersistInlineEditContent(latestDraft, current)
|
|
1936
|
+
? undefined
|
|
1937
|
+
: { recordUndoOnly: true },
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1888
1940
|
}
|
|
1889
1941
|
onInlineEditEnd?.(slide.id);
|
|
1890
1942
|
inlineEditDraftRef.current = null;
|
|
@@ -1923,9 +1975,9 @@ export default function SlideEditor({
|
|
|
1923
1975
|
// resize and auto-fit again on the second click.
|
|
1924
1976
|
setSelectedElementMeasurement(null);
|
|
1925
1977
|
captureInlineEditDraft(slide.id);
|
|
1926
|
-
// Mark the
|
|
1927
|
-
// the
|
|
1928
|
-
// types and triggers an onUpdateSlide flush.
|
|
1978
|
+
// Mark the slide active immediately so SSE/poll refreshes do not replace
|
|
1979
|
+
// the live DOM under an active contentEditable edit, even before the
|
|
1980
|
+
// user types and triggers an onUpdateSlide flush.
|
|
1929
1981
|
onInlineEditStart?.(slide.id);
|
|
1930
1982
|
// Don't override the selection. The browser's native double-click
|
|
1931
1983
|
// word-select (or single-click caret) is already on the element from the
|
|
@@ -1966,10 +2018,12 @@ export default function SlideEditor({
|
|
|
1966
2018
|
draft?.slideId === previousSlideId &&
|
|
1967
2019
|
shouldPersistInlineEditContent(initial, draft)
|
|
1968
2020
|
) {
|
|
1969
|
-
onUpdateSlideRef.current({ content: draft.content }, previousSlideId
|
|
1970
|
-
|
|
2021
|
+
onUpdateSlideRef.current({ content: draft.content }, previousSlideId, {
|
|
2022
|
+
recordUndoOnly: true,
|
|
2023
|
+
});
|
|
1971
2024
|
}
|
|
1972
2025
|
if (editing || draft) onInlineEditEnd?.(previousSlideId);
|
|
2026
|
+
inlineEditDraftRef.current = null;
|
|
1973
2027
|
inlineEditInitialContentRef.current = null;
|
|
1974
2028
|
|
|
1975
2029
|
previousSlideIdRef.current = slide.id;
|
|
@@ -1980,9 +2034,20 @@ export default function SlideEditor({
|
|
|
1980
2034
|
// otherwise never clear and permanently block live sync for that slide.
|
|
1981
2035
|
useEffect(() => {
|
|
1982
2036
|
return () => {
|
|
1983
|
-
|
|
1984
|
-
|
|
2037
|
+
const draft = inlineEditDraftRef.current;
|
|
2038
|
+
const initial = inlineEditInitialContentRef.current;
|
|
2039
|
+
if (shouldPersistInlineEditContent(initial, draft) && draft) {
|
|
2040
|
+
onUpdateSlideRef.current({ content: draft.content }, draft.slideId, {
|
|
2041
|
+
recordUndoOnly: true,
|
|
2042
|
+
});
|
|
1985
2043
|
}
|
|
2044
|
+
if (editingElRef.current || draft) {
|
|
2045
|
+
onInlineEditEndRef.current?.(
|
|
2046
|
+
draft?.slideId ?? previousSlideIdRef.current,
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
inlineEditDraftRef.current = null;
|
|
2050
|
+
inlineEditInitialContentRef.current = null;
|
|
1986
2051
|
};
|
|
1987
2052
|
}, []);
|
|
1988
2053
|
|
|
@@ -68,6 +68,10 @@ function addSlideFields(
|
|
|
68
68
|
export type DeckReloadStatus = "loaded" | "failed" | "stale";
|
|
69
69
|
export interface UpdateSlideOptions {
|
|
70
70
|
persistence?: "debounced" | "immediate";
|
|
71
|
+
/** Queue a draft without replacing the active contentEditable DOM. */
|
|
72
|
+
preserveLocalState?: boolean;
|
|
73
|
+
/** Record the edit for undo without enqueueing a duplicate server write. */
|
|
74
|
+
recordUndoOnly?: boolean;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
// ---------------------------------------------------------------------------
|
|
@@ -382,7 +386,7 @@ const inFlightSaves = new Set<string>();
|
|
|
382
386
|
const inFlightSaveChains = new Map<string, Promise<void>>();
|
|
383
387
|
const inFlightSaveControllers = new Map<string, AbortController>();
|
|
384
388
|
const deckSaveGenerations = new Map<string, number>();
|
|
385
|
-
const immediateFlushRequests = new
|
|
389
|
+
const immediateFlushRequests = new Map<string, boolean>();
|
|
386
390
|
const deckSaveRetryAttempts = new Map<string, number>();
|
|
387
391
|
const failedSaveDecks = new Set<string>();
|
|
388
392
|
const saveStateListeners = new Set<() => void>();
|
|
@@ -400,10 +404,9 @@ const pendingOpsQueue = new Map<string, GranularOp[]>();
|
|
|
400
404
|
// whole request duration, starving unrelated agent writes of live sync.
|
|
401
405
|
const inFlightOpSlides = new Map<string, GranularOp[]>();
|
|
402
406
|
|
|
403
|
-
// Slides currently mid inline-edit (contentEditable open
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// ends, so this is the only signal that catches live, uncommitted typing.
|
|
407
|
+
// Slides currently mid inline-edit (contentEditable open). `onInlineEditStart`
|
|
408
|
+
// /`onInlineEditEnd` keep this current while content captures queue granular
|
|
409
|
+
// ops without replacing the live DOM.
|
|
407
410
|
const activeInlineEditSlides = new Map<string, Set<string>>();
|
|
408
411
|
|
|
409
412
|
/** Mark `slideId` as mid inline-edit so a concurrent agent write to the same
|
|
@@ -502,11 +505,64 @@ function deckPayload(deck: Deck): Record<string, unknown> {
|
|
|
502
505
|
* the `save-deck` action is called first, then any trailing granular ops are
|
|
503
506
|
* sent through `patch-deck`.
|
|
504
507
|
*/
|
|
508
|
+
async function sendKeepaliveAction(
|
|
509
|
+
url: string,
|
|
510
|
+
method: "POST" | "PUT",
|
|
511
|
+
body: Record<string, unknown>,
|
|
512
|
+
signal?: AbortSignal,
|
|
513
|
+
): Promise<void> {
|
|
514
|
+
const response = await fetch(url, {
|
|
515
|
+
method,
|
|
516
|
+
headers: {
|
|
517
|
+
"Content-Type": "application/json",
|
|
518
|
+
"X-Agent-Native-Frontend": "1",
|
|
519
|
+
},
|
|
520
|
+
body: JSON.stringify(body),
|
|
521
|
+
cache: "no-store",
|
|
522
|
+
keepalive: true,
|
|
523
|
+
signal,
|
|
524
|
+
});
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
throw new Error(`Action request failed with status ${response.status}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
505
530
|
async function persistDeckOps(
|
|
506
531
|
deckId: string,
|
|
507
532
|
ops: GranularOp[],
|
|
508
533
|
signal?: AbortSignal,
|
|
534
|
+
options?: { keepalive?: boolean },
|
|
509
535
|
) {
|
|
536
|
+
if (options?.keepalive) {
|
|
537
|
+
const actionsBase = agentNativePath("/_agent-native/actions");
|
|
538
|
+
if (ops[0].op === "full-replace") {
|
|
539
|
+
const deck = ops[0].deck;
|
|
540
|
+
await sendKeepaliveAction(
|
|
541
|
+
`${actionsBase}/save-deck`,
|
|
542
|
+
"PUT",
|
|
543
|
+
{ deckId, deck: deckPayload(deck) },
|
|
544
|
+
signal,
|
|
545
|
+
);
|
|
546
|
+
const trailingOps = ops.slice(1) as PatchDeckOp[];
|
|
547
|
+
if (trailingOps.length > 0) {
|
|
548
|
+
await sendKeepaliveAction(
|
|
549
|
+
`${actionsBase}/patch-deck`,
|
|
550
|
+
"POST",
|
|
551
|
+
{ deckId, operations: trailingOps },
|
|
552
|
+
signal,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
await sendKeepaliveAction(
|
|
557
|
+
`${actionsBase}/patch-deck`,
|
|
558
|
+
"POST",
|
|
559
|
+
{ deckId, operations: ops as PatchDeckOp[] },
|
|
560
|
+
signal,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
510
566
|
if (ops[0].op === "full-replace") {
|
|
511
567
|
// Legacy full-deck write — used by undo/redo and setDeckSlides.
|
|
512
568
|
// `callAction` bounds it so a stalled save can't wedge `inFlightSaves`
|
|
@@ -547,14 +603,21 @@ async function persistDeckOps(
|
|
|
547
603
|
* could otherwise complete out of order and let a stale drag overwrite a
|
|
548
604
|
* newer resize.
|
|
549
605
|
*/
|
|
550
|
-
function drainPendingDeckOps(
|
|
606
|
+
function drainPendingDeckOps(
|
|
607
|
+
deckId: string,
|
|
608
|
+
options?: { keepalive?: boolean },
|
|
609
|
+
): Promise<void> {
|
|
551
610
|
const timer = pendingSaves.get(deckId);
|
|
552
611
|
if (timer) clearTimeout(timer);
|
|
553
612
|
pendingSaves.delete(deckId);
|
|
554
613
|
|
|
555
614
|
const active = inFlightSaveChains.get(deckId);
|
|
556
615
|
if (active) {
|
|
557
|
-
immediateFlushRequests.
|
|
616
|
+
immediateFlushRequests.set(
|
|
617
|
+
deckId,
|
|
618
|
+
(immediateFlushRequests.get(deckId) ?? false) ||
|
|
619
|
+
options?.keepalive === true,
|
|
620
|
+
);
|
|
558
621
|
notifySaveListeners();
|
|
559
622
|
return active;
|
|
560
623
|
}
|
|
@@ -574,7 +637,7 @@ function drainPendingDeckOps(deckId: string): Promise<void> {
|
|
|
574
637
|
inFlightOpSlides.set(deckId, ops);
|
|
575
638
|
const isCurrentGeneration = () =>
|
|
576
639
|
(deckSaveGenerations.get(deckId) ?? 0) === generation;
|
|
577
|
-
const next = persistDeckOps(deckId, ops, controller?.signal)
|
|
640
|
+
const next = persistDeckOps(deckId, ops, controller?.signal, options)
|
|
578
641
|
.then(() => {
|
|
579
642
|
if (!isCurrentGeneration()) return;
|
|
580
643
|
deckSaveRetryAttempts.delete(deckId);
|
|
@@ -613,10 +676,15 @@ function drainPendingDeckOps(deckId: string): Promise<void> {
|
|
|
613
676
|
}
|
|
614
677
|
inFlightSaves.delete(deckId);
|
|
615
678
|
inFlightOpSlides.delete(deckId);
|
|
616
|
-
const
|
|
679
|
+
const requestedFlush = immediateFlushRequests.get(deckId);
|
|
680
|
+
const flushImmediately = requestedFlush !== undefined;
|
|
681
|
+
immediateFlushRequests.delete(deckId);
|
|
617
682
|
notifySaveListeners();
|
|
618
683
|
if (flushImmediately) {
|
|
619
|
-
void drainPendingDeckOps(
|
|
684
|
+
void drainPendingDeckOps(
|
|
685
|
+
deckId,
|
|
686
|
+
requestedFlush ? { keepalive: true } : undefined,
|
|
687
|
+
);
|
|
620
688
|
}
|
|
621
689
|
}
|
|
622
690
|
});
|
|
@@ -661,7 +729,10 @@ async function flushDeckSave(deckId: string): Promise<void> {
|
|
|
661
729
|
function enqueueDeckOp(
|
|
662
730
|
deckId: string,
|
|
663
731
|
op: GranularOp,
|
|
664
|
-
options?: {
|
|
732
|
+
options?: {
|
|
733
|
+
persistence?: "debounced" | "immediate";
|
|
734
|
+
coalesceContent?: boolean;
|
|
735
|
+
},
|
|
665
736
|
) {
|
|
666
737
|
const existing = pendingSaves.get(deckId);
|
|
667
738
|
if (existing) clearTimeout(existing);
|
|
@@ -678,7 +749,21 @@ function enqueueDeckOp(
|
|
|
678
749
|
pendingOpsQueue.set(deckId, [op]);
|
|
679
750
|
} else {
|
|
680
751
|
const queue = pendingOpsQueue.get(deckId) ?? [];
|
|
681
|
-
queue.
|
|
752
|
+
const previous = queue[queue.length - 1];
|
|
753
|
+
if (
|
|
754
|
+
options?.coalesceContent &&
|
|
755
|
+
previous?.op === "patch-slide" &&
|
|
756
|
+
op.op === "patch-slide" &&
|
|
757
|
+
previous.slideId === op.slideId &&
|
|
758
|
+
Object.keys(previous.fields).length === 1 &&
|
|
759
|
+
Object.keys(op.fields).length === 1 &&
|
|
760
|
+
"content" in previous.fields &&
|
|
761
|
+
"content" in op.fields
|
|
762
|
+
) {
|
|
763
|
+
queue[queue.length - 1] = op;
|
|
764
|
+
} else {
|
|
765
|
+
queue.push(op);
|
|
766
|
+
}
|
|
682
767
|
pendingOpsQueue.set(deckId, queue);
|
|
683
768
|
}
|
|
684
769
|
|
|
@@ -704,71 +789,20 @@ function saveDeckToAPI(deck: Deck) {
|
|
|
704
789
|
}
|
|
705
790
|
|
|
706
791
|
/**
|
|
707
|
-
*
|
|
708
|
-
*
|
|
792
|
+
* Flush every pending (debounced) deck op through the same per-deck queue used
|
|
793
|
+
* by normal saves, using keepalive transport so in-flight edits survive a tab
|
|
709
794
|
* close / navigation. Called from a `pagehide` / `visibilitychange(hidden)`
|
|
710
|
-
* handler
|
|
795
|
+
* handler - without it there is a ~500ms window (the debounce) where the
|
|
711
796
|
* user's most recent edits are only in memory and are lost on tab close.
|
|
712
797
|
*
|
|
713
798
|
* keepalive requests are best-effort and capped (~64KB by the browser), which
|
|
714
799
|
* is fine: granular ops are small, and if a full-replace payload is too large
|
|
715
800
|
* to send keepalive the normal debounce/poll path still catches up on reopen.
|
|
716
801
|
*/
|
|
717
|
-
function flushPendingSaves() {
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
const saveDeckUrl = `${actionsBase}/save-deck`;
|
|
721
|
-
for (const [deckId, timer] of pendingSaves) {
|
|
722
|
-
clearTimeout(timer);
|
|
723
|
-
const ops = pendingOpsQueue.get(deckId);
|
|
724
|
-
pendingOpsQueue.delete(deckId);
|
|
725
|
-
if (!ops || ops.length === 0) continue;
|
|
726
|
-
try {
|
|
727
|
-
if (ops[0].op === "full-replace") {
|
|
728
|
-
const deck = ops[0].deck;
|
|
729
|
-
void fetch(saveDeckUrl, {
|
|
730
|
-
method: "PUT",
|
|
731
|
-
headers: {
|
|
732
|
-
"Content-Type": "application/json",
|
|
733
|
-
"X-Agent-Native-Frontend": "1",
|
|
734
|
-
},
|
|
735
|
-
body: JSON.stringify({ deckId, deck }),
|
|
736
|
-
keepalive: true,
|
|
737
|
-
});
|
|
738
|
-
const trailingOps = ops.slice(1) as PatchDeckOp[];
|
|
739
|
-
if (trailingOps.length === 0) continue;
|
|
740
|
-
void fetch(actionUrl, {
|
|
741
|
-
method: "POST",
|
|
742
|
-
headers: {
|
|
743
|
-
"Content-Type": "application/json",
|
|
744
|
-
"X-Agent-Native-Frontend": "1",
|
|
745
|
-
},
|
|
746
|
-
body: JSON.stringify({
|
|
747
|
-
deckId,
|
|
748
|
-
operations: trailingOps,
|
|
749
|
-
}),
|
|
750
|
-
keepalive: true,
|
|
751
|
-
});
|
|
752
|
-
} else {
|
|
753
|
-
void fetch(actionUrl, {
|
|
754
|
-
method: "POST",
|
|
755
|
-
headers: {
|
|
756
|
-
"Content-Type": "application/json",
|
|
757
|
-
"X-Agent-Native-Frontend": "1",
|
|
758
|
-
},
|
|
759
|
-
body: JSON.stringify({
|
|
760
|
-
deckId,
|
|
761
|
-
operations: ops as PatchDeckOp[],
|
|
762
|
-
}),
|
|
763
|
-
keepalive: true,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
} catch {
|
|
767
|
-
// Best-effort — nothing more we can do as the page is unloading.
|
|
768
|
-
}
|
|
802
|
+
export function flushPendingSaves() {
|
|
803
|
+
for (const deckId of [...pendingSaves.keys()]) {
|
|
804
|
+
void drainPendingDeckOps(deckId, { keepalive: true });
|
|
769
805
|
}
|
|
770
|
-
pendingSaves.clear();
|
|
771
|
-
notifySaveListeners();
|
|
772
806
|
}
|
|
773
807
|
|
|
774
808
|
function discardPendingDeckOps(deckId: string) {
|
|
@@ -1680,7 +1714,8 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
1680
1714
|
|
|
1681
1715
|
const hasLocalEdits =
|
|
1682
1716
|
pendingCreateIdsRef.current.has(currentOpenId) ||
|
|
1683
|
-
hasUncommittedDeckChanges(currentOpenId, dirtyDeckIdsRef.current)
|
|
1717
|
+
hasUncommittedDeckChanges(currentOpenId, dirtyDeckIdsRef.current) ||
|
|
1718
|
+
(activeInlineEditSlides.get(currentOpenId)?.size ?? 0) > 0;
|
|
1684
1719
|
|
|
1685
1720
|
if (hasLocalEdits && clientDeck) {
|
|
1686
1721
|
// Content-preserving: only ADD server slides missing locally.
|
|
@@ -2433,23 +2468,57 @@ export function DeckProvider({ children }: { children: ReactNode }) {
|
|
|
2433
2468
|
: "Edit slide";
|
|
2434
2469
|
const before = decksRef.current.find((d) => d.id === deckId);
|
|
2435
2470
|
const op: PatchDeckOp = { op: "patch-slide", slideId, fields: updates };
|
|
2436
|
-
if (
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2471
|
+
if (
|
|
2472
|
+
before &&
|
|
2473
|
+
!deriveInverseOp(before, op) &&
|
|
2474
|
+
!options?.preserveLocalState
|
|
2475
|
+
) {
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
if (options?.recordUndoOnly) {
|
|
2479
|
+
if (before) {
|
|
2480
|
+
setDecksLocal((prev: Deck[]) =>
|
|
2481
|
+
prev.map((d) => {
|
|
2482
|
+
if (d.id !== deckId) return d;
|
|
2483
|
+
return {
|
|
2484
|
+
...d,
|
|
2485
|
+
slides: d.slides.map((s) =>
|
|
2486
|
+
s.id === slideId ? { ...s, ...updates } : s,
|
|
2487
|
+
),
|
|
2488
|
+
updatedAt: new Date().toISOString(),
|
|
2489
|
+
};
|
|
2490
|
+
}),
|
|
2491
|
+
);
|
|
2492
|
+
recordUndo(before, op, {
|
|
2493
|
+
label,
|
|
2494
|
+
coalesceKey: `${deckId}:${slideId}:${Object.keys(updates)
|
|
2495
|
+
.sort()
|
|
2496
|
+
.join(",")}`,
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
if (!options?.preserveLocalState) markDeckDirty(deckId);
|
|
2502
|
+
if (!options?.preserveLocalState) {
|
|
2503
|
+
setDecksLocal((prev: Deck[]) =>
|
|
2504
|
+
prev.map((d) => {
|
|
2505
|
+
if (d.id !== deckId) return d;
|
|
2506
|
+
return {
|
|
2507
|
+
...d,
|
|
2508
|
+
slides: d.slides.map((s) =>
|
|
2509
|
+
s.id === slideId ? { ...s, ...updates } : s,
|
|
2510
|
+
),
|
|
2511
|
+
updatedAt: new Date().toISOString(),
|
|
2512
|
+
};
|
|
2513
|
+
}),
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2450
2516
|
// Granular op — only this slide's changed fields reach the server.
|
|
2451
|
-
enqueueDeckOp(deckId, op,
|
|
2452
|
-
|
|
2517
|
+
enqueueDeckOp(deckId, op, {
|
|
2518
|
+
persistence: options?.persistence,
|
|
2519
|
+
coalesceContent: options?.preserveLocalState,
|
|
2520
|
+
});
|
|
2521
|
+
if (before && !options?.preserveLocalState) {
|
|
2453
2522
|
// Coalesce a burst of edits to the SAME slide's SAME field-set into one
|
|
2454
2523
|
// undo step (e.g. typing characters into inline text). Distinct
|
|
2455
2524
|
// field-sets (content vs background vs layout) get distinct undo steps.
|
|
@@ -160,7 +160,6 @@ export default function DeckEditor() {
|
|
|
160
160
|
addSlide,
|
|
161
161
|
flushDeckSave,
|
|
162
162
|
reorderSlides,
|
|
163
|
-
markDeckDirty,
|
|
164
163
|
undo,
|
|
165
164
|
loading,
|
|
166
165
|
loadError,
|
|
@@ -180,9 +179,13 @@ export default function DeckEditor() {
|
|
|
180
179
|
}, []);
|
|
181
180
|
const [generatingSlideSelected, setGeneratingSlideSelected] = useState(false);
|
|
182
181
|
const { hasUnsavedChanges: hasUnsavedSave } = useSaveState();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
// useSaveState re-renders this component when a preserved inline draft enters
|
|
183
|
+
// or leaves the shared save queue, so the deck-specific read stays current.
|
|
184
|
+
const hasPendingDeckWrites = id ? hasUnsavedDeckChanges(id) : hasUnsavedSave;
|
|
185
|
+
const hasPendingDeckEdits = inlineEditActive || hasPendingDeckWrites;
|
|
186
|
+
// Inline drafts flush through SlideEditor keepalive handlers. The native
|
|
187
|
+
// prompt only needs to cover queued or in-flight writes now.
|
|
188
|
+
usePendingDeckUnloadGuard(hasPendingDeckWrites);
|
|
186
189
|
const pendingDeckNavigationBlocker = useBlocker(
|
|
187
190
|
useCallback(
|
|
188
191
|
({ currentLocation, nextLocation }) =>
|
|
@@ -1773,7 +1776,6 @@ export default function DeckEditor() {
|
|
|
1773
1776
|
}
|
|
1774
1777
|
onInlineEditStart={(slideId) => {
|
|
1775
1778
|
setInlineEditActive(true);
|
|
1776
|
-
markDeckDirty(id);
|
|
1777
1779
|
if (id) markSlideEditingActive(id, slideId);
|
|
1778
1780
|
}}
|
|
1779
1781
|
onInlineEditEnd={(slideId) => {
|
|
@@ -10,5 +10,6 @@ export { createAgentNativeQueryClient } from "../create-query-client.js";
|
|
|
10
10
|
export { AppProviders, type AppProvidersProps } from "../app-providers.js";
|
|
11
11
|
export { APP_CHAT_SIDEBAR_STATE_EVENT, APP_CHAT_SIDEBAR_STATE_MESSAGE, APP_CHAT_SIDEBAR_STATE_REQUEST_MESSAGE, buildAppChatSidebarStateMessage, buildAppChatSidebarStateRequest, isPerAppChatStorageKey, requestPerAppChatCommand, usePerAppChatState, usePerAppChatOpen, type AppChatSidebarCommand, type AppChatSidebarState, } from "../app-chat-sidebar.js";
|
|
12
12
|
export { usePinchZoom, type UsePinchZoomOptions } from "../use-pinch-zoom.js";
|
|
13
|
+
export { isPinchZoomDelta, normalizeWheelDeltaPx, resolveZoomGestureDevice, MAX_PINCH_DELTA_PX, ZOOM_GESTURE_IDLE_RESET_MS, type ZoomGestureDevice, } from "../zoom-gesture.js";
|
|
13
14
|
export { useAvatarUrl, uploadAvatar, invalidateAvatarCache, } from "../use-avatar.js";
|
|
14
15
|
export { usePollLoop, type UsePollLoopOptions, type UsePollLoopHandle, } from "../use-poll-loop.js";
|