@agent-native/core 0.161.6 → 0.161.8

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 (35) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/server/plugins/agent-chat.ts +2 -0
  3. package/corpus/templates/content/server/plugins/feature-flags.ts +5 -0
  4. package/corpus/templates/content/shared/feature-flags.ts +15 -0
  5. package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +9 -0
  6. package/corpus/templates/slides/actions/patch-deck.ts +8 -0
  7. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +188 -66
  8. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +102 -22
  9. package/corpus/templates/slides/app/components/presentation/PresenterView.tsx +84 -2
  10. package/corpus/templates/slides/app/context/DeckContext.tsx +94 -3
  11. package/corpus/templates/slides/app/i18n/en-US.ts +5 -0
  12. package/corpus/templates/slides/app/pages/DeckEditor.tsx +122 -8
  13. package/corpus/templates/videos/package.json +9 -0
  14. package/dist/a2a/correlation.d.ts +6 -6
  15. package/dist/a2a/correlation.js +8 -6
  16. package/dist/a2a/types.d.ts +7 -5
  17. package/dist/collab/awareness.d.ts +2 -2
  18. package/dist/deploy/build.js +8 -0
  19. package/dist/observability/routes.d.ts +5 -5
  20. package/dist/progress/routes.d.ts +1 -1
  21. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  22. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  23. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  24. package/dist/resources/handlers.d.ts +1 -1
  25. package/dist/scripts/call-agent.js +6 -5
  26. package/dist/secrets/routes.d.ts +9 -9
  27. package/dist/server/agent-chat/action-filters-a2a.d.ts +6 -1
  28. package/dist/server/agent-chat/action-filters-a2a.js +29 -2
  29. package/dist/server/agent-chat/plugin-options.d.ts +6 -0
  30. package/dist/server/agent-chat-plugin.d.ts +2 -1
  31. package/dist/server/agent-chat-plugin.js +20 -4
  32. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  33. package/dist/server/realtime-token.d.ts +1 -1
  34. package/dist/server/transcribe-voice.d.ts +1 -1
  35. package/package.json +3 -3
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8289
34
+ - template files: 8292
@@ -5,6 +5,7 @@ import {
5
5
  } from "@agent-native/core/server";
6
6
 
7
7
  import actionsRegistry from "../../.generated/actions-registry.js";
8
+ import { A2A_RECEIVER_OWNERSHIP_FLAG } from "../../shared/feature-flags.js";
8
9
  import {
9
10
  publicDocumentExtraContext,
10
11
  resolvePublicViewerOwner,
@@ -32,6 +33,7 @@ const INITIAL_TOOL_NAMES = [
32
33
  export default createAgentChatPlugin({
33
34
  appId: "content",
34
35
  durableBackgroundRuns: true,
36
+ a2aReceiverOwnershipFlag: A2A_RECEIVER_OWNERSHIP_FLAG,
35
37
  actions: loadActionsFromStaticRegistry(actionsRegistry),
36
38
  initialToolNames: INITIAL_TOOL_NAMES,
37
39
  mcp: {
@@ -0,0 +1,5 @@
1
+ import { createFeatureFlagsPlugin } from "@agent-native/core/server";
2
+
3
+ import { CONTENT_FEATURE_FLAGS } from "../../shared/feature-flags.js";
4
+
5
+ export default createFeatureFlagsPlugin({ flags: CONTENT_FEATURE_FLAGS });
@@ -0,0 +1,15 @@
1
+ import {
2
+ defineFeatureFlag,
3
+ defineFeatureFlags,
4
+ } from "@agent-native/core/feature-flags";
5
+
6
+ export const A2A_RECEIVER_OWNERSHIP_FLAG = defineFeatureFlag({
7
+ key: "content.a2a-receiver-ownership",
8
+ displayName: "A2A receiver ownership",
9
+ description:
10
+ "Prefer Content's declared local capabilities when another app delegates an objective to Content.",
11
+ });
12
+
13
+ export const CONTENT_FEATURE_FLAGS = defineFeatureFlags([
14
+ A2A_RECEIVER_OWNERSHIP_FLAG,
15
+ ]);
@@ -90,6 +90,15 @@ To edit a slide's content:
90
90
  preserve quote, speaker, date, metric, and uncertainty status. Existing HTML
91
91
  or visual similarity is not proof of source fidelity.
92
92
 
93
+ ## Skipping a Slide
94
+
95
+ Set a slide's `skipped: true` via a `patch-deck` `patch-slide` operation to
96
+ exclude it from Present/Presenter playback without deleting it — the slide
97
+ stays in the deck, editor, and exports. Set `skipped: false` (or omit it) to
98
+ include it again. The rail's right-click menu on each slide thumbnail offers
99
+ Cut, Copy, Paste, Delete, New slide, Duplicate slide, and Skip slide as the
100
+ same operations.
101
+
93
102
  ## Click-to-reveal animations
94
103
 
95
104
  Animations are metadata over the final slide HTML, not alternate slide markup.
@@ -113,6 +113,12 @@ const SlideFieldsSchema = z.object({
113
113
  .describe(
114
114
  "Complete ordered on-click reveal list. Include every intended target in order; unlisted elements remain visible. Use elementPath from the final HTML and 0-based indexes.",
115
115
  ),
116
+ skipped: z
117
+ .boolean()
118
+ .optional()
119
+ .describe(
120
+ "Exclude this slide from Present/Presenter playback without deleting it.",
121
+ ),
116
122
  });
117
123
 
118
124
  /** Update fields on a single existing slide */
@@ -165,6 +171,7 @@ const AddSlideOp = z.object({
165
171
  .optional(),
166
172
  animations: z.array(z.unknown()).optional(),
167
173
  splitByParagraph: z.boolean().optional(),
174
+ skipped: z.boolean().optional(),
168
175
  })
169
176
  .passthrough(),
170
177
  });
@@ -346,6 +353,7 @@ export function applyOperation(deck: any, op: Operation): void {
346
353
  slide.excalidrawData = fields.excalidrawData;
347
354
  if (fields.transition !== undefined) slide.transition = fields.transition;
348
355
  if (fields.animations !== undefined) slide.animations = fields.animations;
356
+ if (fields.skipped !== undefined) slide.skipped = fields.skipped;
349
357
  break;
350
358
  }
351
359
 
@@ -17,6 +17,7 @@ import {
17
17
  import { CSS } from "@dnd-kit/utilities";
18
18
  import { appStateKeyForBrowserTab } from "@shared/app-state-tabs";
19
19
  import { hashSlideContent, type DeckFitState } from "@shared/slide-fit";
20
+ import { IconEyeOff } from "@tabler/icons-react";
20
21
  import { useCallback, useEffect, useRef, useState } from "react";
21
22
  import { toast } from "sonner";
22
23
 
@@ -25,6 +26,14 @@ import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer";
25
26
  import { AddSlidePopover } from "@/components/editor/AddSlidePopover";
26
27
  import { AiEditingMarker } from "@/components/editor/AiEditingMarker";
27
28
  import GeneratingSlidePreview from "@/components/editor/GeneratingSlidePreview";
29
+ import {
30
+ ContextMenu,
31
+ ContextMenuContent,
32
+ ContextMenuItem,
33
+ ContextMenuSeparator,
34
+ ContextMenuShortcut,
35
+ ContextMenuTrigger,
36
+ } from "@/components/ui/context-menu";
28
37
  import {
29
38
  Tooltip,
30
39
  TooltipContent,
@@ -33,6 +42,7 @@ import {
33
42
  import { defaultSlideContent, type Slide } from "@/context/DeckContext";
34
43
  import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
35
44
  import { TAB_ID } from "@/lib/tab-id";
45
+ import { shortcutLabel } from "@/lib/utils";
36
46
 
37
47
  import type { DesignSystemData } from "../../../shared/api";
38
48
  import { isSlideTextEditingTarget } from "./slide-text-targets";
@@ -78,6 +88,19 @@ interface EditorSidebarProps {
78
88
  * this component's own useAgentGenerating() call) so the run stays
79
89
  * correctly scoped and trackable across a sidebar remount. */
80
90
  addSlideAgentSubmit: (message: string, context: string) => void;
91
+ /** Whether a slide is currently on the cut/copy clipboard, so the rail's
92
+ * right-click menu can disable Paste when there's nothing to paste. */
93
+ hasSlideClipboard?: boolean;
94
+ onCutSlide?: (slideId: string) => void;
95
+ onCopySlide?: (slideId: string) => void;
96
+ /** Pastes the clipboard slide directly after this slide. */
97
+ onPasteSlide?: (slideId: string) => void;
98
+ onDeleteSlide?: (slideId: string) => void;
99
+ /** Inserts a new blank slide directly after this slide. */
100
+ onNewSlideAfter?: (slideId: string) => void;
101
+ onDuplicateSlide?: (slideId: string) => void;
102
+ /** Toggles whether this slide is excluded from Present/Presenter mode. */
103
+ onToggleSkipSlide?: (slideId: string) => void;
81
104
  }
82
105
 
83
106
  const DECK_FIT_STATE_KEYS = [
@@ -176,6 +199,15 @@ function SortableSlideThumb({
176
199
  onOverflowChange,
177
200
  readOnly = false,
178
201
  aiEditing = false,
202
+ canDelete = true,
203
+ hasSlideClipboard = false,
204
+ onCutSlide,
205
+ onCopySlide,
206
+ onPasteSlide,
207
+ onDeleteSlide,
208
+ onNewSlideAfter,
209
+ onDuplicateSlide,
210
+ onToggleSkipSlide,
179
211
  }: {
180
212
  slide: Slide;
181
213
  index: number;
@@ -188,6 +220,16 @@ function SortableSlideThumb({
188
220
  designSystem?: DesignSystemData;
189
221
  onOverflowChange: (info: SlideOverflowInfo) => void;
190
222
  aiEditing?: boolean;
223
+ /** False when this is the deck's last remaining slide — Cut/Delete stay enabled elsewhere but must not remove it. */
224
+ canDelete?: boolean;
225
+ hasSlideClipboard?: boolean;
226
+ onCutSlide?: (slideId: string) => void;
227
+ onCopySlide?: (slideId: string) => void;
228
+ onPasteSlide?: (slideId: string) => void;
229
+ onDeleteSlide?: (slideId: string) => void;
230
+ onNewSlideAfter?: (slideId: string) => void;
231
+ onDuplicateSlide?: (slideId: string) => void;
232
+ onToggleSkipSlide?: (slideId: string) => void;
191
233
  }) {
192
234
  const t = useT();
193
235
  const {
@@ -217,76 +259,139 @@ function SortableSlideThumb({
217
259
 
218
260
  return (
219
261
  <div ref={setNodeRef} style={style}>
220
- <button
221
- ref={(node) => registerButtonRef(slide.id, node)}
222
- type="button"
223
- {...(readOnly ? {} : attributes)}
224
- {...(readOnly ? {} : listeners)}
225
- onClick={(event) => {
226
- // Safari does not focus a button on click, and the slide copy/paste
227
- // and delete shortcuts are scoped to a focused thumbnail.
228
- event.currentTarget.focus();
229
- onSelect();
230
- }}
231
- onFocus={onSelect}
232
- aria-label={t("editorSidebar.selectSlide", { number: index + 1 })}
233
- aria-current={isActive ? "true" : undefined}
234
- data-slide-thumbnail-id={slide.id}
235
- className={`w-full text-left flex items-start gap-1.5 p-1.5 rounded-lg transition-[background-color,box-shadow] duration-150 ${
236
- isActive ? "bg-accent" : ""
237
- } ${
238
- readOnly ? "" : "cursor-grab active:cursor-grabbing"
239
- } focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1`}
240
- >
241
- {/* Index and slide presence share the fixed rail so presence does not resize the row. */}
242
- <div className="relative flex-shrink-0 w-4 self-stretch">
243
- <span className="block text-center text-[10px] font-medium leading-5 text-muted-foreground/70">
244
- {index + 1}
245
- </span>
246
- {(showAiMarker || humanPresenceUsers.length > 0) && (
247
- <div className="absolute left-1/2 top-5 z-10 flex -translate-x-1/2 flex-col items-center gap-1">
248
- {showAiMarker && (
249
- <AiEditingMarker className="size-4 text-[8px]" />
250
- )}
251
- {humanPresenceUsers.slice(0, 4).map((u, i) => (
252
- <PresenceAvatarTip key={i} user={u} size={14} />
253
- ))}
254
- {humanPresenceUsers.length > 4 && (
255
- <span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[8px] font-medium leading-none text-muted-foreground ring-1 ring-black/40">
256
- +{humanPresenceUsers.length - 4}
257
- </span>
262
+ <ContextMenu>
263
+ <ContextMenuTrigger asChild disabled={readOnly}>
264
+ <button
265
+ ref={(node) => registerButtonRef(slide.id, node)}
266
+ type="button"
267
+ {...(readOnly ? {} : attributes)}
268
+ {...(readOnly ? {} : listeners)}
269
+ onClick={(event) => {
270
+ // Safari does not focus a button on click, and the slide copy/paste
271
+ // and delete shortcuts are scoped to a focused thumbnail.
272
+ event.currentTarget.focus();
273
+ onSelect();
274
+ }}
275
+ onFocus={onSelect}
276
+ aria-label={t("editorSidebar.selectSlide", { number: index + 1 })}
277
+ aria-current={isActive ? "true" : undefined}
278
+ data-slide-thumbnail-id={slide.id}
279
+ className={`w-full text-left flex items-start gap-1.5 p-1.5 rounded-lg transition-[background-color,box-shadow] duration-150 ${
280
+ isActive ? "bg-accent" : ""
281
+ } ${
282
+ readOnly ? "" : "cursor-grab active:cursor-grabbing"
283
+ } focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1`}
284
+ >
285
+ {/* Index and slide presence share the fixed rail so presence does not resize the row. */}
286
+ <div className="relative flex-shrink-0 w-4 self-stretch">
287
+ <span className="block text-center text-[10px] font-medium leading-5 text-muted-foreground/70">
288
+ {index + 1}
289
+ </span>
290
+ {(showAiMarker || humanPresenceUsers.length > 0) && (
291
+ <div className="absolute left-1/2 top-5 z-10 flex -translate-x-1/2 flex-col items-center gap-1">
292
+ {showAiMarker && (
293
+ <AiEditingMarker className="size-4 text-[8px]" />
294
+ )}
295
+ {humanPresenceUsers.slice(0, 4).map((u, i) => (
296
+ <PresenceAvatarTip key={i} user={u} size={14} />
297
+ ))}
298
+ {humanPresenceUsers.length > 4 && (
299
+ <span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[8px] font-medium leading-none text-muted-foreground ring-1 ring-black/40">
300
+ +{humanPresenceUsers.length - 4}
301
+ </span>
302
+ )}
303
+ </div>
258
304
  )}
259
305
  </div>
260
- )}
261
- </div>
262
306
 
263
- {/* Thumbnail */}
264
- <div className="flex-1 min-w-0">
265
- {/* Each thumbnail paints a full-resolution slide canvas scaled down by
266
- * transform, so a long rail keeps dozens of large layers live and the
267
- * browser drops frames or paints stale tiles. `content-visibility`
268
- * lets it skip everything below the fold; `aspect-ratio` keeps the row
269
- * the right height while its contents are skipped. */}
270
- <div
271
- className="w-full overflow-hidden rounded border"
272
- style={{
273
- borderColor:
274
- humanPresenceUsers.length > 0
275
- ? humanPresenceUsers[0].color + "66"
276
- : "rgba(255,255,255,0.06)",
277
- aspectRatio: `${thumbDims.width} / ${thumbDims.height}`,
278
- contentVisibility: "auto",
279
- }}
307
+ {/* Thumbnail */}
308
+ <div className="flex-1 min-w-0">
309
+ {/* Each thumbnail paints a full-resolution slide canvas scaled down by
310
+ * transform, so a long rail keeps dozens of large layers live and the
311
+ * browser drops frames or paints stale tiles. `content-visibility`
312
+ * lets it skip everything below the fold; `aspect-ratio` keeps the row
313
+ * the right height while its contents are skipped. */}
314
+ <div
315
+ className={`relative w-full overflow-hidden rounded border ${
316
+ slide.skipped ? "opacity-40" : ""
317
+ }`}
318
+ style={{
319
+ borderColor:
320
+ humanPresenceUsers.length > 0
321
+ ? humanPresenceUsers[0].color + "66"
322
+ : // guard:allow-raw-color — thumbnail border sits on an arbitrary-colored slide render, not app chrome
323
+ "rgba(255,255,255,0.06)",
324
+ aspectRatio: `${thumbDims.width} / ${thumbDims.height}`,
325
+ contentVisibility: "auto",
326
+ }}
327
+ >
328
+ <SlideRenderer
329
+ slide={slide}
330
+ aspectRatio={aspectRatio}
331
+ designSystem={designSystem}
332
+ onOverflowChange={onOverflowChange}
333
+ />
334
+ {slide.skipped && (
335
+ // guard:allow-raw-color — dims an arbitrary-colored slide render, not app chrome; must stay black regardless of theme
336
+ <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
337
+ {/* guard:allow-raw-color — icon sits on the black scrim above, not app chrome */}
338
+ <IconEyeOff className="size-6 text-white/80" />
339
+ </div>
340
+ )}
341
+ </div>
342
+ </div>
343
+ </button>
344
+ </ContextMenuTrigger>
345
+ <ContextMenuContent
346
+ onCloseAutoFocus={(event) => {
347
+ // Radix restores focus to the trigger (this slide's thumbnail
348
+ // button) when the menu closes. That button's onFocus reselects
349
+ // its own slide, which clobbers New/Duplicate/Paste's deliberate
350
+ // selection of the newly created slide. Our handlers already set
351
+ // the right active slide explicitly, so skip the auto-focus.
352
+ event.preventDefault();
353
+ }}
354
+ >
355
+ <ContextMenuItem
356
+ disabled={!canDelete}
357
+ onSelect={() => onCutSlide?.(slide.id)}
280
358
  >
281
- <SlideRenderer
282
- slide={slide}
283
- aspectRatio={aspectRatio}
284
- designSystem={designSystem}
285
- onOverflowChange={onOverflowChange}
286
- />
287
- </div>
288
- </div>
289
- </button>
359
+ {t("editorSidebar.cut")}
360
+ <ContextMenuShortcut>{shortcutLabel("cmd+x")}</ContextMenuShortcut>
361
+ </ContextMenuItem>
362
+ <ContextMenuItem onSelect={() => onCopySlide?.(slide.id)}>
363
+ {t("editorSidebar.copy")}
364
+ <ContextMenuShortcut>{shortcutLabel("cmd+c")}</ContextMenuShortcut>
365
+ </ContextMenuItem>
366
+ <ContextMenuItem
367
+ disabled={!hasSlideClipboard}
368
+ onSelect={() => onPasteSlide?.(slide.id)}
369
+ >
370
+ {t("editorSidebar.paste")}
371
+ <ContextMenuShortcut>{shortcutLabel("cmd+v")}</ContextMenuShortcut>
372
+ </ContextMenuItem>
373
+ <ContextMenuSeparator />
374
+ <ContextMenuItem onSelect={() => onNewSlideAfter?.(slide.id)}>
375
+ {t("editorSidebar.newSlide")}
376
+ </ContextMenuItem>
377
+ <ContextMenuItem onSelect={() => onDuplicateSlide?.(slide.id)}>
378
+ {t("editorSidebar.duplicateSlide")}
379
+ </ContextMenuItem>
380
+ <ContextMenuItem onSelect={() => onToggleSkipSlide?.(slide.id)}>
381
+ {slide.skipped
382
+ ? t("editorSidebar.unskipSlide")
383
+ : t("editorSidebar.skipSlide")}
384
+ </ContextMenuItem>
385
+ <ContextMenuSeparator />
386
+ <ContextMenuItem
387
+ disabled={!canDelete}
388
+ onSelect={() => onDeleteSlide?.(slide.id)}
389
+ className="text-destructive focus:text-destructive"
390
+ >
391
+ {t("editorSidebar.deleteSlide")}
392
+ </ContextMenuItem>
393
+ </ContextMenuContent>
394
+ </ContextMenu>
290
395
  </div>
291
396
  );
292
397
  }
@@ -352,6 +457,14 @@ export default function EditorSidebar({
352
457
  onAwaitAddSlidePersisted,
353
458
  onRemoveFailedSlide,
354
459
  addSlideAgentSubmit,
460
+ hasSlideClipboard,
461
+ onCutSlide,
462
+ onCopySlide,
463
+ onPasteSlide,
464
+ onDeleteSlide,
465
+ onNewSlideAfter,
466
+ onDuplicateSlide,
467
+ onToggleSkipSlide,
355
468
  }: EditorSidebarProps) {
356
469
  const t = useT();
357
470
  const [describeAnchorEl, setDescribeAnchorEl] =
@@ -549,6 +662,15 @@ export default function EditorSidebar({
549
662
  aspectRatio={aspectRatio}
550
663
  designSystem={designSystem}
551
664
  aiEditing={aiEditedSlideIds.has(slide.id)}
665
+ canDelete={slides.length > 1}
666
+ hasSlideClipboard={hasSlideClipboard}
667
+ onCutSlide={onCutSlide}
668
+ onCopySlide={onCopySlide}
669
+ onPasteSlide={onPasteSlide}
670
+ onDeleteSlide={onDeleteSlide}
671
+ onNewSlideAfter={onNewSlideAfter}
672
+ onDuplicateSlide={onDuplicateSlide}
673
+ onToggleSkipSlide={onToggleSkipSlide}
552
674
  onOverflowChange={(info) =>
553
675
  handleSlideOverflowChange(slide, info)
554
676
  }
@@ -202,7 +202,7 @@ export default function PresentationView({
202
202
  const safeSlides = useMemo(
203
203
  () =>
204
204
  (Array.isArray(slides) ? slides : [])
205
- .filter(Boolean)
205
+ .filter((slide): slide is Slide => Boolean(slide) && !slide.skipped)
206
206
  .map((slide, index) => ({
207
207
  ...slide,
208
208
  id: slide.id || `slide-${index}`,
@@ -212,6 +212,27 @@ export default function PresentationView({
212
212
  })),
213
213
  [slides],
214
214
  );
215
+ // `startIndex` is a raw index into the full (unfiltered) deck.slides array —
216
+ // e.g. from the editor's current slide or a `?slide=N` deep link. Skipped
217
+ // slides are absent from safeSlides, so translate it to the nearest visible
218
+ // slide's position within safeSlides rather than clamping the raw index
219
+ // directly, which would land on the wrong slide whenever a skip precedes it.
220
+ const initialIndex = useMemo(() => {
221
+ const rawSlides = (Array.isArray(slides) ? slides : []).filter(Boolean);
222
+ if (rawSlides.length === 0) return 0;
223
+ const clampedRaw = Math.max(0, Math.min(startIndex, rawSlides.length - 1));
224
+ for (let i = clampedRaw; i < rawSlides.length; i++) {
225
+ if (!rawSlides[i]?.skipped) {
226
+ return rawSlides.slice(0, i).filter((s) => !s?.skipped).length;
227
+ }
228
+ }
229
+ for (let i = clampedRaw - 1; i >= 0; i--) {
230
+ if (!rawSlides[i]?.skipped) {
231
+ return rawSlides.slice(0, i).filter((s) => !s?.skipped).length;
232
+ }
233
+ }
234
+ return 0;
235
+ }, [slides, startIndex]);
215
236
  const clampIndex = useCallback(
216
237
  (index: number) => {
217
238
  if (safeSlides.length === 0) return 0;
@@ -221,7 +242,7 @@ export default function PresentationView({
221
242
  [safeSlides.length],
222
243
  );
223
244
  const [currentIndex, setCurrentIndex] = useState(() =>
224
- clampIndex(startIndex),
245
+ clampIndex(initialIndex),
225
246
  );
226
247
  const [prevIndex, setPrevIndex] = useState<number | null>(null);
227
248
  const [direction, setDirection] = useState<"next" | "prev">("next");
@@ -239,24 +260,36 @@ export default function PresentationView({
239
260
 
240
261
  const isShared = deckId.startsWith("__shared__/");
241
262
 
263
+ // `safeSlides` excludes skipped slides, so its index isn't the deck index
264
+ // DeckEditor's `?slide=N` param expects. Map back to the raw position so
265
+ // exiting/opening Presenter lands on the same slide it's currently showing.
266
+ const visibleRawIndices = useMemo(() => {
267
+ const rawSlides = (Array.isArray(slides) ? slides : []).filter(Boolean);
268
+ const rawIndices: number[] = [];
269
+ rawSlides.forEach((slide, i) => {
270
+ if (!slide?.skipped) rawIndices.push(i);
271
+ });
272
+ return rawIndices;
273
+ }, [slides]);
274
+ const toRawIndex = useCallback(
275
+ (filteredIndex: number) =>
276
+ visibleRawIndices[filteredIndex] ?? filteredIndex,
277
+ [visibleRawIndices],
278
+ );
279
+
242
280
  // Exit handlers read these instead of closing over `currentIndex`/`deckId`/
243
281
  // `isShared` so the mount-only fullscreenchange listener still lands on the
244
282
  // right deck and slide even if this component is reused for a different
245
283
  // deck without remounting (e.g. an agent-driven navigation).
246
284
  const currentIndexRef = useRef(currentIndex);
247
285
  currentIndexRef.current = currentIndex;
286
+ const visibleRawIndicesRef = useRef(visibleRawIndices);
287
+ visibleRawIndicesRef.current = visibleRawIndices;
248
288
  const deckIdRef = useRef(deckId);
249
289
  deckIdRef.current = deckId;
250
290
  const isSharedRef = useRef(isShared);
251
291
  isSharedRef.current = isShared;
252
292
 
253
- useEffect(() => {
254
- setCurrentIndex((prev) => clampIndex(prev));
255
- setPrevIndex((prev) =>
256
- prev !== null && prev >= safeSlides.length ? null : prev,
257
- );
258
- }, [clampIndex, safeSlides.length]);
259
-
260
293
  const clearTransitionTimer = useCallback(() => {
261
294
  if (transitionTimerRef.current !== null) {
262
295
  window.clearTimeout(transitionTimerRef.current);
@@ -266,14 +299,57 @@ export default function PresentationView({
266
299
 
267
300
  useEffect(() => clearTransitionTimer, [clearTransitionTimer]);
268
301
 
302
+ // One atomic effect handles both cases so they can't race each other:
303
+ // - A genuine deep link or deck switch (startIndex/deckId changed) reseeds
304
+ // from initialIndex. This component is reused across deck navigation
305
+ // (see the exit-handler refs above), so a new deck at the same `?slide=`
306
+ // must still reset instead of inheriting the previous deck's position.
307
+ // - Otherwise, a skip toggle or reorder changed safeSlides without a new
308
+ // deep link. A length-only clamp would silently swap in a different
309
+ // slide at the same index, so follow the previously-shown slide's id to
310
+ // its new position, falling back to a raw clamp only when it's gone.
311
+ const prevDeepLinkKeyRef = useRef({ startIndex, deckId });
312
+ const prevSafeSlideIdsRef = useRef<string[]>(safeSlides.map((s) => s.id));
269
313
  useEffect(() => {
270
- clearTransitionTimer();
271
- queuedNavigationRef.current = null;
272
- setCurrentIndex(clampIndex(startIndex));
273
- setCurrentStep(0);
274
- setPrevIndex(null);
275
- setAnimating(false);
276
- }, [clearTransitionTimer, clampIndex, startIndex]);
314
+ const prevKey = prevDeepLinkKeyRef.current;
315
+ const isDeepLinkChange =
316
+ prevKey.startIndex !== startIndex || prevKey.deckId !== deckId;
317
+ prevDeepLinkKeyRef.current = { startIndex, deckId };
318
+
319
+ if (isDeepLinkChange) {
320
+ prevSafeSlideIdsRef.current = safeSlides.map((s) => s.id);
321
+ clearTransitionTimer();
322
+ queuedNavigationRef.current = null;
323
+ setCurrentIndex(clampIndex(initialIndex));
324
+ setCurrentStep(0);
325
+ setPrevIndex(null);
326
+ setAnimating(false);
327
+ return;
328
+ }
329
+
330
+ // Read the prior ids before overwriting the ref below — the lookup needs
331
+ // the slide order from before this update, not the one it's producing.
332
+ const activeId = prevSafeSlideIdsRef.current[currentIndexRef.current];
333
+ const newIds = safeSlides.map((s) => s.id);
334
+ const followedIndex = activeId ? newIds.indexOf(activeId) : -1;
335
+ prevSafeSlideIdsRef.current = newIds;
336
+ setCurrentIndex(
337
+ followedIndex >= 0 ? followedIndex : clampIndex(currentIndexRef.current),
338
+ );
339
+ setPrevIndex((prev) =>
340
+ prev !== null && prev >= safeSlides.length ? null : prev,
341
+ );
342
+ if (followedIndex < 0) {
343
+ // The active slide is gone (e.g. just skipped) and we fell back to a
344
+ // different one — its reveal/transition state doesn't apply here.
345
+ clearTransitionTimer();
346
+ queuedNavigationRef.current = null;
347
+ setCurrentStep(0);
348
+ setPrevIndex(null);
349
+ setAnimating(false);
350
+ }
351
+ // eslint-disable-next-line react-hooks/exhaustive-deps
352
+ }, [safeSlides, startIndex, deckId]);
277
353
 
278
354
  const currentSlide = safeSlides[currentIndex];
279
355
  const animSteps = currentSlide ? getAnimationSteps(currentSlide) : null;
@@ -359,7 +435,10 @@ export default function PresentationView({
359
435
  const token = deckId.replace("__shared__/", "");
360
436
  navigate(`/share/${token}`);
361
437
  } else {
362
- navigate(`/deck/${deckId}?slide=${currentIndexRef.current + 1}`);
438
+ const rawIndex =
439
+ visibleRawIndicesRef.current[currentIndexRef.current] ??
440
+ currentIndexRef.current;
441
+ navigate(`/deck/${deckId}?slide=${rawIndex + 1}`);
363
442
  }
364
443
  }, [navigate, deckId, isShared]);
365
444
 
@@ -405,13 +484,13 @@ export default function PresentationView({
405
484
  const openPresenterWindow = useCallback(() => {
406
485
  const url = new URL(window.location.href);
407
486
  url.searchParams.set("presenter", "1");
408
- url.searchParams.set("slide", String(currentIndex + 1));
487
+ url.searchParams.set("slide", String(toRawIndex(currentIndex) + 1));
409
488
  window.open(
410
489
  url.toString(),
411
490
  `slides-presenter-${deckId}`,
412
491
  "width=1200,height=760",
413
492
  );
414
- }, [currentIndex, deckId]);
493
+ }, [currentIndex, deckId, toRawIndex]);
415
494
 
416
495
  useEffect(() => {
417
496
  const handleKey = (e: KeyboardEvent) => {
@@ -480,9 +559,10 @@ export default function PresentationView({
480
559
  const token = deckIdRef.current.replace("__shared__/", "");
481
560
  navigate(`/share/${token}`);
482
561
  } else {
483
- navigate(
484
- `/deck/${deckIdRef.current}?slide=${currentIndexRef.current + 1}`,
485
- );
562
+ const rawIndex =
563
+ visibleRawIndicesRef.current[currentIndexRef.current] ??
564
+ currentIndexRef.current;
565
+ navigate(`/deck/${deckIdRef.current}?slide=${rawIndex + 1}`);
486
566
  }
487
567
  }
488
568
  };