@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.
Files changed (64) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
  3. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
  4. package/corpus/templates/clips/desktop/src/app.tsx +171 -51
  5. package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
  6. package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
  8. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
  9. package/corpus/templates/clips/desktop/src/main.tsx +4 -4
  10. package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
  11. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
  12. package/corpus/templates/clips/desktop/src/styles.css +20 -308
  13. package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
  14. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
  15. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
  16. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
  17. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
  18. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
  19. package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
  20. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
  21. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
  22. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
  23. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
  24. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
  25. package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
  26. package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
  28. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
  29. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
  30. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
  31. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
  32. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
  33. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
  34. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
  35. package/corpus/templates/design/shared/canvas-math.ts +11 -1
  36. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
  37. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
  38. package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
  40. package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
  41. package/dist/client/hooks/index.d.ts +1 -0
  42. package/dist/client/hooks/index.js +1 -0
  43. package/dist/client/use-pinch-zoom.js +17 -4
  44. package/dist/client/zoom-gesture.d.ts +74 -0
  45. package/dist/client/zoom-gesture.js +100 -0
  46. package/dist/collab/routes.d.ts +1 -1
  47. package/dist/collab/struct-routes.d.ts +1 -1
  48. package/dist/observability/routes.d.ts +3 -3
  49. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  50. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/builder-browser.d.ts +13 -13
  53. package/dist/server/builder-browser.js +46 -15
  54. package/dist/server/builder-oauth.d.ts +12 -0
  55. package/dist/server/builder-oauth.js +13 -2
  56. package/dist/server/core-routes-plugin.js +54 -19
  57. package/dist/server/realtime-token.d.ts +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/client.js +2 -0
  60. package/docs/content/template-chat-developers.mdx +100 -13
  61. package/docs/content/template-chat-first-edits.mdx +5 -5
  62. package/docs/content/template-chat.mdx +84 -36
  63. package/package.json +4 -3
  64. 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
- const currentIndex = slides.findIndex((s) => s.id === activeSlideId);
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
- }, [slides, activeSlideId, onSelectSlide]);
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 type { Slide, UpdateSlideOptions } from "@/context/DeckContext";
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 call `markDeckDirty(deckId)` here so the SSE/poll
662
- * reconcile path knows not to replace the deck under an active in-progress
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
- !inlineEditInitialContentRef.current ||
1610
- inlineEditInitialContentRef.current.slideId !== slideId
1655
+ previous?.slideId === slideId &&
1656
+ previous.content !== next.content
1611
1657
  ) {
1612
- inlineEditInitialContentRef.current = { slideId, content: html };
1658
+ onUpdateSlideRef.current({ content: html }, slideId, {
1659
+ preserveLocalState: true,
1660
+ });
1613
1661
  }
1614
- inlineEditDraftRef.current = { slideId, content: html };
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
- html !== null &&
1882
- shouldPersistInlineEditContent(initial, {
1883
- slideId: slide.id,
1884
- content: html,
1885
- })
1886
- ) {
1887
- onUpdateSlideRef.current({ content: html });
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 deck dirty immediately so SSE/poll refreshes do not replace
1927
- // the deck under an active contentEditable edit, even before the user
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
- inlineEditDraftRef.current = null;
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
- if (editingElRef.current || inlineEditDraftRef.current) {
1984
- onInlineEditEndRef.current?.(previousSlideIdRef.current);
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 Set<string>();
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, keystrokes not yet
404
- // committed to a "patch-slide" op). `onInlineEditStart`/`onInlineEditEnd`
405
- // keep this current; a slide only reaches `pendingOpsQueue` when editing
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(deckId: string): Promise<void> {
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.add(deckId);
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 flushImmediately = immediateFlushRequests.delete(deckId);
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(deckId);
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?: { persistence?: "debounced" | "immediate" },
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.push(op);
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
- * Synchronously flush every pending (debounced) deck op queue using
708
- * `fetch(..., { keepalive: true })` so in-flight edits survive a tab
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 — without it there is a ~500ms window (the debounce) where the
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 actionsBase = agentNativePath("/_agent-native/actions");
719
- const actionUrl = `${actionsBase}/patch-deck`;
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 (before && !deriveInverseOp(before, op)) return;
2437
- markDeckDirty(deckId);
2438
- setDecksLocal((prev: Deck[]) =>
2439
- prev.map((d) => {
2440
- if (d.id !== deckId) return d;
2441
- return {
2442
- ...d,
2443
- slides: d.slides.map((s) =>
2444
- s.id === slideId ? { ...s, ...updates } : s,
2445
- ),
2446
- updatedAt: new Date().toISOString(),
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, options);
2452
- if (before) {
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
- const hasPendingDeckEdits =
184
- inlineEditActive || (id ? hasUnsavedDeckChanges(id) : hasUnsavedSave);
185
- usePendingDeckUnloadGuard(hasPendingDeckEdits);
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) => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-26
4
+ ---
5
+
6
+ Clicking a slide thumbnail now keeps arrow keys on slide navigation instead of scrolling the thumbnail pane.
@@ -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";