@agent-native/core 0.146.1 → 0.146.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 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: 7903
34
+ - template files: 7904
@@ -2,7 +2,6 @@ import path from "path";
2
2
 
3
3
  import { defineAction } from "@agent-native/core";
4
4
  import { writeAppState } from "@agent-native/core/application-state";
5
- import { uploadFile } from "@agent-native/core/file-upload";
6
5
  import { startBuilderDesignSystemIndex } from "@agent-native/core/server";
7
6
  import {
8
7
  getRequestOrgId,
@@ -273,21 +272,16 @@ export default defineAction({
273
272
  const { PDFParse, canvasFactory } = await setupPdfParse();
274
273
  const title = titleFromPath(filename);
275
274
 
276
- // Designed slide PDFs (photo backgrounds, gradients, custom
277
- // typography) bake their visuals into vector/image page content with
278
- // no reliable text/shape structure to reconstruct, so importing into a
279
- // deck rasterizes each page instead of flattening it to generic bullet
280
- // text. Do not silently fall back to text reconstruction when the
281
- // renderer is unavailable: that is how a visually complete source deck
282
- // turns into a misleading, content-lossy import.
275
+ // Reconstruct each page's real layout — positioned text blocks at
276
+ // their actual sizes plus every embedded image at its actual
277
+ // placement — instead of flattening the page to one guessed
278
+ // background photo and a canned text template. Image placement needs
279
+ // the optional canvas renderer; text positioning does not, so this
280
+ // still beats the old bullet-text fallback even when canvasFactory is
281
+ // unavailable in this runtime.
283
282
  if (importIntoDeck) {
284
- if (!canvasFactory) {
285
- throw new Error(
286
- "Source-faithful PDF import is unavailable in this runtime because the page renderer is not configured. Retry after connecting file storage or use a PDF export with a supported Slides deployment.",
287
- );
288
- }
289
283
  if (!deckId) throw new Error("deckId is required to import into deck");
290
- return importPdfPagesAsFullBleedSlides({
284
+ return importPdfPagesWithFidelity({
291
285
  fileBuffer,
292
286
  title,
293
287
  deckId,
@@ -351,60 +345,61 @@ function nearestAspectRatio(width: number, height: number): AspectRatio {
351
345
  return best;
352
346
  }
353
347
 
354
- async function importPdfPagesAsFullBleedSlides(args: {
348
+ async function importPdfPagesWithFidelity(args: {
355
349
  fileBuffer: Buffer;
356
350
  title: string;
357
351
  deckId: string;
358
352
  PDFParse: Awaited<ReturnType<typeof setupPdfParse>>["PDFParse"];
359
- canvasFactory: object;
353
+ canvasFactory: object | undefined;
360
354
  }) {
361
355
  const { fileBuffer, title, deckId, PDFParse, canvasFactory } = args;
362
- const { buildFullPageImageSlideHtml } =
356
+ const { convertToSlideHtml, convertSectionsToSlides } =
363
357
  await import("../server/handlers/import/html-converter.js");
358
+ const { parsePdfFidelity } =
359
+ await import("../server/handlers/import/pdf-fidelity-parser.js");
364
360
 
365
361
  const pdf = new PDFParse({
366
362
  data: new Uint8Array(fileBuffer),
367
363
  CanvasFactory: canvasFactory,
368
364
  });
369
365
  let pages: { num: number; text: string }[];
370
- let screenshotsByPage: Map<
371
- number,
372
- { data: Uint8Array; width: number; height: number }
373
- >;
366
+ let fidelityPages: Awaited<ReturnType<typeof parsePdfFidelity>>;
374
367
  try {
375
- let textPages: { num: number; text: string }[] = [];
376
- try {
377
- textPages = normalizePdfPages(await pdf.getText());
378
- } catch (error) {
379
- console.warn(
380
- "[import-file] PDF text extraction failed; preserving rendered pages and leaving notes empty:",
381
- error,
382
- );
383
- }
384
- // Rasterize the complete page so positioned text, vector shapes, logos,
385
- // photos, and the source page proportions survive the import together.
386
- const screenshotResult = await pdf.getScreenshot({
387
- desiredWidth: 1600,
388
- imageBuffer: true,
389
- imageDataUrl: false,
390
- });
391
- screenshotsByPage = new Map();
392
- for (const page of screenshotResult.pages) {
393
- screenshotsByPage.set(page.pageNumber, {
394
- data: page.data,
395
- width: page.width,
396
- height: page.height,
397
- });
398
- }
399
- if (screenshotsByPage.size === 0) {
400
- throw new Error("The PDF renderer returned no pages.");
401
- }
402
- pages = screenshotResult.pages.map((page) => ({
403
- num: page.pageNumber,
404
- text:
405
- textPages.find((textPage) => textPage.num === page.pageNumber)?.text ??
406
- "",
407
- }));
368
+ pages = normalizePdfPages(await pdf.getText());
369
+ // Image placement needs the optional canvas renderer to decode pixel
370
+ // data; skip it (text still gets real positions/sizes) when the native
371
+ // canvas binding isn't available in this runtime.
372
+ const imageResult = canvasFactory
373
+ ? await pdf
374
+ .getImage({
375
+ imageBuffer: true,
376
+ imageDataUrl: false,
377
+ imageThreshold: 0,
378
+ })
379
+ .catch((err) => {
380
+ console.warn(
381
+ "[import-file] PDF image extraction failed, importing text-only fidelity:",
382
+ err instanceof Error ? err.message : String(err),
383
+ );
384
+ return undefined;
385
+ })
386
+ : undefined;
387
+
388
+ // `pdf-parse` memoizes the loaded pdfjs document behind `load()` (a
389
+ // TS-only `private` method — a real, callable runtime property).
390
+ // Reaching into it reuses the exact document `getText`/`getImage` above
391
+ // already parsed instead of parsing the file a second time.
392
+ const doc = await (
393
+ pdf as unknown as {
394
+ load(): Promise<
395
+ import("pdfjs-dist/legacy/build/pdf.mjs").PDFDocumentProxy
396
+ >;
397
+ }
398
+ ).load();
399
+ // coercion-ok: undefined here means either canvasFactory was absent or
400
+ // getImage() already failed and logged a warning above — text-only
401
+ // fidelity is the intended degrade, not a swallowed failure.
402
+ fidelityPages = await parsePdfFidelity(doc, imageResult?.pages ?? []);
408
403
  } finally {
409
404
  await pdf.destroy();
410
405
  }
@@ -413,60 +408,110 @@ async function importPdfPagesAsFullBleedSlides(args: {
413
408
  throw new Error("The PDF renderer returned no importable pages.");
414
409
  }
415
410
 
411
+ // A page with neither extracted text nor a fidelity element is only ever
412
+ // produced when nothing on it could be recovered (e.g. a scanned/image
413
+ // page and canvas rendering was unavailable or failed) — if that's true of
414
+ // every page, importing anyway would silently create a deck of blank
415
+ // placeholder slides and report success, matching the earlier
416
+ // text-extraction path's "needs OCR" failure keeps that lossy import from
417
+ // going unnoticed.
418
+ const hasRecoverableContent = pages.some((page) => {
419
+ const fidelity = fidelityPages.find((p) => p.pageNumber === page.num);
420
+ return page.text.trim().length > 0 || (fidelity?.elements.length ?? 0) > 0;
421
+ });
422
+ if (!hasRecoverableContent) {
423
+ throw new Error(
424
+ "No importable text or images found in this PDF. Scanned PDFs need OCR first.",
425
+ );
426
+ }
427
+
416
428
  // Source decks (e.g. Instagram carousel exports) are commonly portrait or
417
429
  // square, not the deck editor's 16:9 default — match the canvas to the
418
- // first rendered page's own proportions instead of stretching/cropping it.
419
- const firstScreenshot = pages
420
- .map((page) => screenshotsByPage.get(page.num))
421
- .find((image): image is NonNullable<typeof image> => Boolean(image));
422
- const aspectRatio = firstScreenshot
423
- ? nearestAspectRatio(firstScreenshot.width, firstScreenshot.height)
430
+ // PDF's own real page proportions instead of stretching/cropping it.
431
+ const firstSizedPage = fidelityPages.find((p) => p.widthEmu > 0);
432
+ const aspectRatio = firstSizedPage
433
+ ? nearestAspectRatio(firstSizedPage.widthEmu, firstSizedPage.heightEmu)
424
434
  : undefined;
425
435
 
426
436
  const ownerEmail = getRequestUserEmail();
437
+ if (!ownerEmail) throw new Error("no authenticated user");
438
+
439
+ // Bounded the same way as the PPTX upload path below — an unbounded
440
+ // `Promise.all` here would fire one image-upload batch per page at once,
441
+ // and a large deck can be dozens of pages.
442
+ const uploadLimit = pLimit(4);
427
443
  const imported = await Promise.all(
428
- pages.map(async (page) => {
429
- const screenshot = screenshotsByPage.get(page.num);
444
+ pages.map((page, index) =>
445
+ uploadLimit(async () => {
446
+ const fidelity = fidelityPages.find((p) => p.pageNumber === page.num);
447
+
448
+ if (!fidelity || fidelity.elements.length === 0) {
449
+ // Fidelity parsing failed or found nothing placeable on this page
450
+ // (e.g. a fully blank page) — fall back to plain extracted text
451
+ // instead of producing a silently blank slide.
452
+ const firstLine = page.text.split(/\r?\n/)[0]?.trim();
453
+ const [content] = convertSectionsToSlides([
454
+ { heading: firstLine || `Page ${page.num}`, content: page.text },
455
+ ]);
456
+ const id = newSlideId();
457
+ return {
458
+ slide: {
459
+ id,
460
+ content: content ?? '<div class="fmd-slide"></div>',
461
+ layout: "content",
462
+ notes: page.text,
463
+ },
464
+ snapshot: {
465
+ id,
466
+ text: page.text,
467
+ notes: page.text,
468
+ imageUrls: [],
469
+ editableText: true,
470
+ } satisfies SourceImportSlideSnapshot,
471
+ };
472
+ }
430
473
 
431
- if (!screenshot) {
432
- throw new Error(
433
- `The PDF renderer did not return page ${page.num}; refusing a partial source import.`,
474
+ const slideForUpload = {
475
+ texts: [],
476
+ images: [],
477
+ elements: fidelity.elements,
478
+ };
479
+ const uploaded = await uploadPptxSlideImages({
480
+ slide: slideForUpload,
481
+ slideIndex: index,
482
+ ownerEmail,
483
+ });
484
+ const content = convertToSlideHtml(
485
+ {
486
+ texts: [],
487
+ images: [],
488
+ elements: fidelity.elements,
489
+ widthEmu: fidelity.widthEmu,
490
+ heightEmu: fidelity.heightEmu,
491
+ // A page with no detected full-page fill is plain paper.
492
+ backgroundColor: fidelity.backgroundColor ?? "#ffffff", // guard:allow-raw-color - fallback plain-paper background, not a design-system token
493
+ },
494
+ uploaded.urls,
434
495
  );
435
- }
436
496
 
437
- const uploadResult = await uploadFile({
438
- data: Buffer.from(screenshot.data),
439
- filename: `slide-import-${Date.now()}-p${page.num}.png`,
440
- mimeType: "image/png",
441
- ownerEmail: ownerEmail ?? undefined,
442
- recordAsset: false,
443
- });
444
- if (!uploadResult?.url) {
445
- throw new Error(
446
- "File storage is not configured. Connect Builder.io (free tier available) or another upload provider before importing PDF slides.",
447
- );
448
- }
449
- const id = newSlideId();
450
- return {
451
- slide: {
452
- id,
453
- content: buildFullPageImageSlideHtml(
454
- uploadResult.url,
455
- screenshot.width,
456
- screenshot.height,
457
- ),
458
- layout: "full-image",
459
- notes: page.text,
460
- },
461
- snapshot: {
462
- id,
463
- text: page.text,
464
- notes: page.text,
465
- imageUrls: [uploadResult.url],
466
- editableText: false,
467
- } satisfies SourceImportSlideSnapshot,
468
- };
469
- }),
497
+ const id = newSlideId();
498
+ return {
499
+ slide: {
500
+ id,
501
+ content,
502
+ layout: "content",
503
+ notes: page.text,
504
+ },
505
+ snapshot: {
506
+ id,
507
+ text: page.text,
508
+ notes: page.text,
509
+ imageUrls: Object.values(uploaded.urls),
510
+ editableText: true,
511
+ } satisfies SourceImportSlideSnapshot,
512
+ };
513
+ }),
514
+ ),
470
515
  );
471
516
  const slides = imported.map((entry) => entry.slide);
472
517
  const sourceImport = buildSourceImportMetadata({
@@ -96,7 +96,10 @@ interface EditorToolbarProps {
96
96
  onShowHistory: () => void;
97
97
  historyButtonRef: React.RefObject<HTMLButtonElement | null>;
98
98
  currentSlide?: Slide;
99
- onUpdateSlide?: (updates: Partial<Omit<Slide, "id">>) => void;
99
+ onUpdateSlide?: (
100
+ updates: Partial<Omit<Slide, "id">>,
101
+ slideIdOverride?: string,
102
+ ) => void;
100
103
  /** Active users on the current slide (from collab awareness) */
101
104
  activeUsers?: CollabUser[];
102
105
  /** Whether the agent has a durable presence entry on this slide */
@@ -352,7 +355,7 @@ export default function EditorToolbar({
352
355
  // That row rides on SlideEditor, which only mounts for a real slide, so an
353
356
  // empty deck must keep this fallback or it has no way to add one.
354
357
  const contextToolbarVisible = canEdit && Boolean(currentSlide);
355
- const { undo, redo, canUndo, canRedo } = useDecks();
358
+ const { undo, redo, canUndo, canRedo, getDeck } = useDecks();
356
359
  const fileInputRef = useRef<HTMLInputElement>(null);
357
360
  const [importing, setImporting] = useState(false);
358
361
  const { setTheme, resolvedTheme } = useTheme();
@@ -553,7 +556,7 @@ export default function EditorToolbar({
553
556
  >
554
557
  <div className="py-1.5">
555
558
  {/* Layout section */}
556
- <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
559
+ <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
557
560
  {t("editorToolbar.layout")}
558
561
  </div>
559
562
  {slideLayoutOptions.map((opt) => (
@@ -573,7 +576,7 @@ export default function EditorToolbar({
573
576
 
574
577
  {/* Background section */}
575
578
  <div className="mx-2 my-1.5 border-t border-border" />
576
- <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
579
+ <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
577
580
  {t("editorToolbar.background")}
578
581
  </div>
579
582
  <div className="px-3 pb-2">
@@ -619,7 +622,7 @@ export default function EditorToolbar({
619
622
 
620
623
  {/* Image & Assets section */}
621
624
  <div className="mx-2 my-1.5 border-t border-border" />
622
- <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
625
+ <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
623
626
  {t("editorToolbar.media")}
624
627
  </div>
625
628
  <button
@@ -647,27 +650,46 @@ export default function EditorToolbar({
647
650
 
648
651
  {/* Diagrams section */}
649
652
  <div className="mx-2 my-1.5 border-t border-border" />
650
- <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
653
+ <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
651
654
  {t("editorToolbar.diagrams")}
652
655
  </div>
653
656
  <button
654
- onClick={() => {
657
+ onClick={async () => {
655
658
  if (!onUpdateSlide || !currentSlide) return;
656
- const mermaidTemplate = `<div class="fmd-slide" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:60px 80px;font-family:'Poppins',sans-serif;">
657
- <div class="mermaid">
658
- graph TD
659
+ // Pin the slide this insert targets before the async
660
+ // import/parse below, so navigating to another slide in the
661
+ // meantime can't redirect where the diagram lands.
662
+ const targetSlideId = currentSlide.id;
663
+ const defaultMermaidDefinition = `graph TD
659
664
  A[Start] --> B{Decision}
660
665
  B -->|Yes| C[Action A]
661
666
  B -->|No| D[Action B]
662
667
  C --> E[End]
663
- D --> E
664
- </div>
665
- </div>`;
666
- onUpdateSlide({
667
- content: mermaidTemplate,
668
- layout: "blank",
669
- });
670
- setLayoutOpen(false);
668
+ D --> E`;
669
+ try {
670
+ const { convertMermaidToExcalidraw } =
671
+ await import("./MermaidToExcalidrawPanel");
672
+ const data = await convertMermaidToExcalidraw(
673
+ defaultMermaidDefinition,
674
+ );
675
+ // The target slide may have been deleted while the import/
676
+ // parse above was in flight — patch-deck rejects the whole
677
+ // batch for a missing slide id, so re-check with the live
678
+ // deck (not the stale `currentSlide` this closure captured)
679
+ // and drop the result instead of enqueueing a dead op.
680
+ const stillExists = getDeck(deckId)?.slides.some(
681
+ (s) => s.id === targetSlideId,
682
+ );
683
+ if (!stillExists) return;
684
+ onUpdateSlide({ excalidrawData: data }, targetSlideId);
685
+ setLayoutOpen(false);
686
+ } catch (err) {
687
+ console.error("Insert Mermaid diagram failed:", err);
688
+ toast.error(t("editorToolbar.insertMermaidFailed"), {
689
+ description:
690
+ err instanceof Error ? err.message : undefined,
691
+ });
692
+ }
671
693
  }}
672
694
  className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
673
695
  >
@@ -696,6 +718,8 @@ graph TD
696
718
  <button
697
719
  onClick={async () => {
698
720
  if (!onUpdateSlide || !currentSlide) return;
721
+ // Same pinning as the insert handler above.
722
+ const targetSlideId = currentSlide.id;
699
723
  try {
700
724
  const match = currentSlide.content.match(
701
725
  /<div\s+class="mermaid"[^>]*>([\s\S]*?)<\/div>/i,
@@ -706,13 +730,22 @@ graph TD
706
730
  const data = await convertMermaidToExcalidraw(
707
731
  match[1].trim(),
708
732
  );
709
- onUpdateSlide({ excalidrawData: data });
733
+ // Same stale-target guard as the insert handler above.
734
+ const stillExists = getDeck(deckId)?.slides.some(
735
+ (s) => s.id === targetSlideId,
736
+ );
737
+ if (!stillExists) return;
738
+ onUpdateSlide({ excalidrawData: data }, targetSlideId);
710
739
  setLayoutOpen(false);
711
- } catch (err: any) {
740
+ } catch (err) {
712
741
  console.error("Mermaid to Excalidraw failed:", err);
742
+ toast.error(t("editorToolbar.convertMermaidFailed"), {
743
+ description:
744
+ err instanceof Error ? err.message : undefined,
745
+ });
713
746
  }
714
747
  }}
715
- className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-[hsl(var(--accent-cyan))]/80 hover:text-[hsl(var(--accent-cyan))] hover:bg-accent/50 transition-colors"
748
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-[hsl(var(--accent-cyan))] hover:bg-accent/50 transition-colors"
716
749
  >
717
750
  <IconTransform className="w-3 h-3" />
718
751
  {t("editorToolbar.convertMermaidToExcalidraw")}
@@ -734,7 +767,7 @@ graph TD
734
767
 
735
768
  {/* Transitions section */}
736
769
  <div className="mx-2 my-1.5 border-t border-border" />
737
- <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
770
+ <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
738
771
  {t("editorToolbar.transition")}
739
772
  </div>
740
773
  <div className="px-3 pb-2.5 grid grid-cols-4 gap-1">
@@ -767,7 +800,7 @@ graph TD
767
800
  {onSetAspectRatio && (
768
801
  <>
769
802
  <div className="mx-2 my-1.5 border-t border-border" />
770
- <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wider">
803
+ <div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
771
804
  {t("editorToolbar.aspectRatio")}
772
805
  </div>
773
806
  <div className="px-3 pb-2.5 grid grid-cols-4 gap-1">
@@ -4409,6 +4409,7 @@ export default function SlideEditor({
4409
4409
 
4410
4410
  <DrawOverlay
4411
4411
  visible={!!drawMode}
4412
+ scopeKey={slideId || slide.id}
4412
4413
  onClose={() => onExitDrawMode?.()}
4413
4414
  onSend={(annotations, instruction, canvasSize) => {
4414
4415
  const summary = annotations
@@ -31,7 +31,10 @@ export function SlideOverflowWarning({
31
31
  <div
32
32
  role="status"
33
33
  aria-live="polite"
34
- className="absolute -top-12 left-0 z-20 flex items-center gap-2 rounded-md border border-foreground/40 px-2 py-1 text-xs text-foreground"
34
+ // w-max/nowrap: the containing block is the zoomed slide canvas, so at
35
+ // low zoom the banner would otherwise wrap to a height taller than its
36
+ // own -top-12 offset and spill down over the slide.
37
+ className="absolute -top-12 left-0 z-20 flex w-max items-center gap-2 whitespace-nowrap rounded-md border border-foreground/40 px-2 py-1 text-xs text-foreground"
35
38
  onPointerDown={(event) => event.stopPropagation()}
36
39
  onClick={(event) => event.stopPropagation()}
37
40
  >
@@ -143,6 +143,27 @@ export type DeckPersistenceResult =
143
143
  | { persisted: false; reason: "request-failed"; error: unknown }
144
144
  | { persisted: false; reason: "not-found" };
145
145
 
146
+ /**
147
+ * Ask the server whether a deck row exists, keeping "absent" and "could not
148
+ * check" distinct — a rejected write is not by itself proof the row is missing.
149
+ */
150
+ async function probeDeckPersisted(id: string): Promise<DeckPersistenceResult> {
151
+ try {
152
+ const result = await callAction<unknown>(
153
+ "get-deck",
154
+ { id },
155
+ {
156
+ method: "GET",
157
+ },
158
+ );
159
+ return normalizeActionDeck(result)
160
+ ? { persisted: true }
161
+ : { persisted: false, reason: "not-found" };
162
+ } catch (error) {
163
+ return { persisted: false, reason: "request-failed", error };
164
+ }
165
+ }
166
+
146
167
  export function describeDeckPersistenceFailure(
147
168
  result: DeckPersistenceResult,
148
169
  fallback: string,
@@ -178,6 +199,7 @@ interface DeckContextType {
178
199
  sourceDeckId: string,
179
200
  newId: string,
180
201
  title?: string,
202
+ onFailure?: () => void,
181
203
  ) => Deck | null;
182
204
  deleteDeck: (id: string) => void;
183
205
  updateDeck: (
@@ -851,18 +873,52 @@ async function fetchDeckListLightFromAPI(): Promise<{ id: string }[] | null> {
851
873
  }
852
874
  }
853
875
 
876
+ const DECK_FETCH_RETRY_ATTEMPTS = 3;
877
+ const DECK_FETCH_RETRY_DELAY_MS = 400;
878
+
879
+ // `get-deck` returns a real 404/403 only when the deck is genuinely gone or
880
+ // the caller genuinely lacks access (see actions/get-deck.ts). A network blip
881
+ // or 5xx is transient and must not be coerced into the same "not found" null
882
+ // the caller uses to show the owner-facing "deck unavailable" pane — that
883
+ // flashed a wrong message on brief server hiccups even though the deck still
884
+ // existed.
885
+ function isConfirmedDeckAbsence(err: unknown): boolean {
886
+ const status = (err as { status?: unknown } | null)?.status;
887
+ return status === 404 || status === 403;
888
+ }
889
+
890
+ // A timeout already made the caller wait the full action timeout window once
891
+ // (see DEFAULT_ACTION_TIMEOUT_MS in use-action.ts); retrying would multiply
892
+ // that wait instead of surfacing the failure — same reasoning as
893
+ // `isActionTimeout` in the shared action-query retry policy.
894
+ function isDeckFetchTimeout(err: unknown): boolean {
895
+ return (err as { timedOut?: unknown } | null)?.timedOut === true;
896
+ }
897
+
854
898
  async function fetchDeckFromAPI(id: string): Promise<Deck | null> {
855
- try {
856
- const result = await callAction<unknown>(
857
- "get-deck",
858
- { id },
859
- { method: "GET" },
860
- );
861
- return normalizeActionDeck(result);
862
- } catch (err) {
863
- console.error(`Failed to fetch deck ${id}:`, err);
864
- return null;
899
+ for (let attempt = 1; attempt <= DECK_FETCH_RETRY_ATTEMPTS; attempt++) {
900
+ try {
901
+ const result = await callAction<unknown>(
902
+ "get-deck",
903
+ { id },
904
+ { method: "GET" },
905
+ );
906
+ return normalizeActionDeck(result);
907
+ } catch (err) {
908
+ if (
909
+ isConfirmedDeckAbsence(err) ||
910
+ isDeckFetchTimeout(err) ||
911
+ attempt === DECK_FETCH_RETRY_ATTEMPTS
912
+ ) {
913
+ console.error(`Failed to fetch deck ${id}:`, err);
914
+ return null;
915
+ }
916
+ await new Promise((resolve) =>
917
+ setTimeout(resolve, attempt * DECK_FETCH_RETRY_DELAY_MS),
918
+ );
919
+ }
865
920
  }
921
+ return null;
866
922
  }
867
923
 
868
924
  export function deckIdFromPathname(pathname: string): string | null {
@@ -1776,24 +1832,18 @@ export function DeckProvider({ children }: { children: ReactNode }) {
1776
1832
  }
1777
1833
  }
1778
1834
 
1779
- try {
1780
- const result = await callAction<unknown>(
1781
- "get-deck",
1782
- { id },
1783
- { method: "GET" },
1784
- );
1785
- return normalizeActionDeck(result)
1786
- ? { persisted: true }
1787
- : { persisted: false, reason: "not-found" };
1788
- } catch (error) {
1789
- return { persisted: false, reason: "request-failed", error };
1790
- }
1835
+ return probeDeckPersisted(id);
1791
1836
  },
1792
1837
  [],
1793
1838
  );
1794
1839
 
1795
1840
  const duplicateDeck = useCallback(
1796
- (sourceDeckId: string, newId: string, title?: string): Deck | null => {
1841
+ (
1842
+ sourceDeckId: string,
1843
+ newId: string,
1844
+ title?: string,
1845
+ onFailure?: () => void,
1846
+ ): Deck | null => {
1797
1847
  if (pendingDuplicateSourceIdsRef.current.has(sourceDeckId)) return null;
1798
1848
  const source = decks.find((d) => d.id === sourceDeckId);
1799
1849
  if (!source) return null;
@@ -1843,10 +1893,26 @@ export function DeckProvider({ children }: { children: ReactNode }) {
1843
1893
  ).then(() => undefined);
1844
1894
  pendingCreatePromisesRef.current.set(newId, duplicatePromise);
1845
1895
  duplicatePromise
1846
- .catch((err) => {
1896
+ .catch(async (err) => {
1897
+ // A rejected request is not proof the row is missing: a timeout or
1898
+ // dropped response can land after the server committed the insert.
1899
+ // Discarding the copy then would delete work that actually exists
1900
+ // and tell the user it failed, so confirm against the server first.
1901
+ const probe = await probeDeckPersisted(newId);
1902
+ if (probe.persisted) {
1903
+ console.warn(
1904
+ `Duplicate request for ${newId} failed but the deck persisted:`,
1905
+ err,
1906
+ );
1907
+ return;
1908
+ }
1847
1909
  console.error("Duplicate failed:", err);
1848
- // Roll back: drop the optimistic deck from local state.
1910
+ // Roll back: drop the optimistic deck from local state. The caller
1911
+ // (via onFailure) is responsible for navigating away if the user is
1912
+ // still sitting on this now-gone deck's route — otherwise they're
1913
+ // stranded on a "Deck unavailable" screen with no way back.
1849
1914
  setDecks((prev) => prev.filter((d) => d.id !== newId));
1915
+ onFailure?.();
1850
1916
  })
1851
1917
  .finally(() => {
1852
1918
  pendingCreateIdsRef.current.delete(newId);