import { agentNativePath, appBasePath, } from "@agent-native/core/client/api-path"; import { createLocalOpUndoController, type LocalOpUndoController, type LocalOpUndoEntry, } from "@agent-native/core/client/collab"; import { callAction } from "@agent-native/core/client/hooks"; import { isEmbedAuthActive } from "@agent-native/core/client/host"; import { useOrg } from "@agent-native/core/client/org"; import { nanoid } from "nanoid"; import { createContext, useContext, useState, useEffect, useCallback, useRef, useSyncExternalStore, ReactNode, } from "react"; import type { AspectRatio } from "@/lib/aspect-ratios"; // --------------------------------------------------------------------------- // Granular persistence types // These mirror the Operation types in actions/patch-deck.ts but are kept // client-side only so the build doesn't pull in server-only imports. // --------------------------------------------------------------------------- type GranularOp = | { op: "patch-slide"; slideId: string; fields: Partial>; } | { op: "delete-slide"; slideId: string; allowEmpty?: boolean } | { op: "reorder-slides"; orderedIds: string[] } | { op: "add-slide"; slideId: string; afterSlideId?: string; fields: { content: string; notes?: string; layout?: string; background?: string; }; } | { op: "patch-deck-fields"; fields: Partial< Omit >; } /** Sentinel: discard all accumulated ops and do a full PUT instead. */ | { op: "full-replace"; deck: Deck }; export type PatchDeckOp = Exclude; // --------------------------------------------------------------------------- // Inverse-op undo // --------------------------------------------------------------------------- // Undo/redo is per-user and is granular for ordinary slide/deck-field edits. // Deck lifecycle and generated/imported full replacements use explicit // deck-level ops because those user actions are whole-resource mutations. export type DeckUndoOp = | ({ deckId: string } & PatchDeckOp) | { op: "delete-deck"; deckId: string } | { op: "restore-deck"; deckId: string; deck: Deck; index?: number } | { op: "replace-deck"; deckId: string; deck: Deck }; export type SlideLayout = | "title" | "section" | "content" | "two-column" | "image" | "statement" | "full-image" | "blank"; export interface Slide { id: string; content: string; notes: string; layout: SlideLayout; background?: string; /** URL of the generated/loaded image for this slide */ imageUrl?: string; /** If true, an image is currently being generated for this slide */ imageLoading?: boolean; /** Prompt used to generate the image */ imagePrompt?: string; /** Excalidraw scene data (elements + appState + files) as JSON string */ excalidrawData?: string; /** Slide transition animation when entering this slide */ transition?: "instant" | "none" | "fade" | "slide" | "zoom"; /** Per-element animations (ordered). Each click reveals the next step. */ animations?: SlideAnimation[]; /** @deprecated Use animations instead */ splitByParagraph?: boolean; } export type AnimationType = "appear" | "fade" | "slide-up" | "zoom"; export interface SlideAnimation { id: string; /** Index of the child element within the content container */ elementIndex: number; /** Preferred target: child-index path from the outer `.fmd-slide` wrapper. */ elementPath?: number[]; type: AnimationType; } export interface Deck { id: string; title: string; createdAt: string; updatedAt: string; slides: Slide[]; /** Share token if this deck has been shared */ shareToken?: string; /** Framework sharing visibility — private (default), org, or public. */ visibility?: "private" | "org" | "public"; /** True when the current user owns this deck. */ createdByMe?: boolean; /** ID of the design system applied to this deck */ designSystemId?: string; /** Per-deck tweak overrides (accent color, title case, etc.) */ tweaks?: Record; /** Slide aspect ratio (defaults to 16:9 when absent for backwards compat) */ aspectRatio?: AspectRatio; } interface DeckContextType { decks: Deck[]; loading: boolean; loadError: boolean; createDeck: ( title?: string, options?: { noDefaultSlides?: boolean; designSystemId?: string | null }, ) => Deck; ensureDeckPersisted: (id: string) => Promise; /** * Optimistically duplicate a deck. Inserts a copy into local state with the * supplied `newId` immediately so the UI can navigate without awaiting the * server, then fires the duplicate-deck action in the background. On error, * the optimistic deck is rolled back. * * Returns the optimistic deck (or `null` if the source deck isn't found). */ duplicateDeck: ( sourceDeckId: string, newId: string, title?: string, ) => Deck | null; deleteDeck: (id: string) => void; updateDeck: ( id: string, updates: Partial>, ) => void; reloadDecks: () => Promise; getDeck: (id: string) => Deck | undefined; addSlide: ( deckId: string, layout?: SlideLayout, afterIndex?: number, ) => string; updateSlide: ( deckId: string, slideId: string, updates: Partial>, ) => void; deleteSlide: (deckId: string, slideId: string) => void; duplicateSlide: (deckId: string, slideId: string) => void; reorderSlides: (deckId: string, oldIndex: number, newIndex: number) => void; setDeckSlides: (deckId: string, slides: Slide[]) => void; /** * Mark a deck as having uncommitted local changes without modifying its data. * Use this when the user begins an interaction (e.g. inline text editing) that * hasn't yet flushed a slide update, so SSE/poll refreshes do not clobber the * in-progress edit. */ markDeckDirty: (deckId: string) => void; // Undo/Redo — per-user inverse-op undo (see DeckUndoOp above). undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; } const DeckContext = createContext(null); const OPEN_DECK_FALLBACK_POLL_MS = 5_000; const DECK_LIST_FALLBACK_POLL_MS = 15_000; // Safety-net interval used while the SSE channel is actually connected. The // fast intervals above are for when the live channel is genuinely down; running // them unconditionally cost an idle deck page ~36 requests/minute. const LIVE_CHANNEL_IDLE_POLL_MS = 60_000; // Bounded exponential backoff for SSE reconnect after a fatal error (e.g. a // non-2xx response, which EventSource treats as terminal and never retries // on its own — see the SSE effect below). Doubles from BASE up to MAX. const SSE_RECONNECT_BASE_MS = 1_000; const SSE_RECONNECT_MAX_MS = 30_000; /** * How long to wait before the next fallback poll. The poll only takes over at * its fast intervals when the live channel is genuinely not carrying updates; * while SSE is connected it drops to a slow safety net. */ export function fallbackPollIntervalMs(state: { liveChannelConnected: boolean; hasOpenDeck: boolean; }): number { if (state.liveChannelConnected) return LIVE_CHANNEL_IDLE_POLL_MS; return state.hasOpenDeck ? OPEN_DECK_FALLBACK_POLL_MS : DECK_LIST_FALLBACK_POLL_MS; } type DeckListActionResult = { decks?: unknown[]; }; type DuplicateDeckActionResult = { id: string; title: string; slideCount: number; url?: string; }; function normalizeActionDeck(value: unknown): Deck | null { if (!value || typeof value !== "object") return null; const deck = value as Partial; if (typeof deck.id !== "string") return null; return { ...deck, id: deck.id, title: typeof deck.title === "string" ? deck.title : "Untitled", createdAt: typeof deck.createdAt === "string" ? deck.createdAt : deck.updatedAt || "", updatedAt: typeof deck.updatedAt === "string" ? deck.updatedAt : deck.createdAt || "", slides: Array.isArray(deck.slides) ? deck.slides : [], } as Deck; } // Debounced save to API + save-state listeners (so the toolbar indicator // can show "Saving…" / "Saved"). The map tracks pending debounce timers; // `inFlight` tracks active fetches. Combined, they answer "is anything // uncommitted?" for the indicator. const pendingSaves = new Map>(); const inFlightSaves = new Set(); const saveStateListeners = new Set<() => void>(); // Per-deck queue of granular ops waiting to be flushed. Keys are deck IDs. // Ops are appended by enqueueDeckOp and drained when the debounce fires. const pendingOpsQueue = new Map(); // Cached snapshot for useSyncExternalStore. MUST be stable when the boolean // is unchanged or React will infinite-loop (it compares snapshots with // Object.is — a fresh object literal every call schedules a new update, // which calls getSnapshot again, which returns a new object… etc). let cachedSnapshot: { saving: boolean } = { saving: false }; function recomputeSnapshot() { const saving = pendingSaves.size > 0 || inFlightSaves.size > 0; if (saving !== cachedSnapshot.saving) { cachedSnapshot = { saving }; } } function notifySaveListeners() { recomputeSnapshot(); saveStateListeners.forEach((fn) => { try { fn(); } catch {} }); } /** Subscribe to save-state changes — used by `useSaveState`. */ export function subscribeSaveState(listener: () => void): () => void { saveStateListeners.add(listener); return () => saveStateListeners.delete(listener); } /** Snapshot of save state — true when anything is debounced or in flight. */ export function getSaveSnapshot(): { saving: boolean } { return cachedSnapshot; } /** * `Deck` is an interface, so it has no implicit index signature and cannot be * passed straight to an action that takes an opaque JSON deck payload. */ function deckPayload(deck: Deck): Record { return { ...deck }; } /** * Enqueue a granular operation for a deck and (re-)arm the debounce. * * When a `full-replace` op is enqueued, all previously-queued ops for that * deck are discarded because the full replace already captures the authoritative * state at that moment (used by undo/redo and bulk generation which produce a * known good snapshot). Later granular edits inside the same debounce window * must still be appended after that snapshot so quick follow-up user edits are * not dropped on reload. * * The debounce fires after 500 ms of quiet, draining the queue via the * granular `patch-deck` action. If the queue starts with a `full-replace` op, * the `save-deck` action is called first, then any trailing granular ops are * sent through `patch-deck`. */ function enqueueDeckOp(deckId: string, op: GranularOp) { // Clear any pending save timer — we're about to reset it const existing = pendingSaves.get(deckId); if (existing) clearTimeout(existing); if (op.op === "full-replace") { // Discard any accumulated granular ops — this is a wholesale replacement pendingOpsQueue.set(deckId, [op]); } else { const queue = pendingOpsQueue.get(deckId) ?? []; queue.push(op); pendingOpsQueue.set(deckId, queue); } // Arm the debounce const timer = setTimeout(async () => { pendingSaves.delete(deckId); inFlightSaves.add(deckId); notifySaveListeners(); const ops = pendingOpsQueue.get(deckId) ?? []; pendingOpsQueue.delete(deckId); try { if (ops.length === 0) return; if (ops[0].op === "full-replace") { // Legacy full-deck write — used by undo/redo and setDeckSlides. // `callAction` bounds it so a stalled save can't wedge `inFlightSaves` // forever (its `finally` cleanup below only runs once this await // settles; an AbortError from the timeout still reaches it). const deck = ops[0].deck; await callAction( "save-deck", { deckId, deck: deckPayload(deck) }, { method: "PUT" }, ); const trailingOps = ops.slice(1) as PatchDeckOp[]; if (trailingOps.length > 0) { await callAction("patch-deck", { deckId, operations: trailingOps, }); } } else { // Granular patch — concurrent-safe await callAction("patch-deck", { deckId, operations: ops as PatchDeckOp[], }); } } catch (err) { console.error(`Failed to save deck ${deckId}:`, err); } finally { inFlightSaves.delete(deckId); notifySaveListeners(); } }, 500); pendingSaves.set(deckId, timer); notifySaveListeners(); } /** * @deprecated Use enqueueDeckOp for new callers. This legacy helper still * does a full-deck `save-deck` write and is kept only for the initial deck * creation path which already inserts via `add-deck` — it is NOT called for * edits any more. */ function saveDeckToAPI(deck: Deck) { enqueueDeckOp(deck.id, { op: "full-replace", deck }); } /** * Synchronously flush every pending (debounced) deck op queue using * `fetch(..., { keepalive: true })` so in-flight edits survive a tab * close / navigation. Called from a `pagehide` / `visibilitychange(hidden)` * handler — without it there is a ~500ms window (the debounce) where the * user's most recent edits are only in memory and are lost on tab close. * * keepalive requests are best-effort and capped (~64KB by the browser), which * is fine: granular ops are small, and if a full-replace payload is too large * to send keepalive the normal debounce/poll path still catches up on reopen. */ function flushPendingSaves() { const actionsBase = agentNativePath("/_agent-native/actions"); const actionUrl = `${actionsBase}/patch-deck`; const saveDeckUrl = `${actionsBase}/save-deck`; for (const [deckId, timer] of pendingSaves) { clearTimeout(timer); const ops = pendingOpsQueue.get(deckId); pendingOpsQueue.delete(deckId); if (!ops || ops.length === 0) continue; try { if (ops[0].op === "full-replace") { const deck = ops[0].deck; void fetch(saveDeckUrl, { method: "PUT", headers: { "Content-Type": "application/json", "X-Agent-Native-Frontend": "1", }, body: JSON.stringify({ deckId, deck }), keepalive: true, }); const trailingOps = ops.slice(1) as PatchDeckOp[]; if (trailingOps.length === 0) continue; void fetch(actionUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Native-Frontend": "1", }, body: JSON.stringify({ deckId, operations: trailingOps, }), keepalive: true, }); } else { void fetch(actionUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Native-Frontend": "1", }, body: JSON.stringify({ deckId, operations: ops as PatchDeckOp[], }), keepalive: true, }); } } catch { // Best-effort — nothing more we can do as the page is unloading. } } pendingSaves.clear(); notifySaveListeners(); } function discardPendingDeckOps(deckId: string) { const timer = pendingSaves.get(deckId); if (timer) clearTimeout(timer); pendingSaves.delete(deckId); pendingOpsQueue.delete(deckId); notifySaveListeners(); } // --------------------------------------------------------------------------- // Local op application + inverse derivation (for inverse-op undo) // --------------------------------------------------------------------------- // These mirror the server-side merge in actions/patch-deck.ts but operate on // the in-memory Deck[] so undo/redo can apply optimistically. They are pure: // they return a new slides array / deck rather than mutating in place. /** Fields carried by a `patch-deck-fields` op. */ type PatchDeckFields = Extract< PatchDeckOp, { op: "patch-deck-fields" } >["fields"]; /** * Apply a single granular op to a deck's slides/fields, returning the updated * Deck. Unknown/no-op cases (slide already gone, etc.) return the deck * unchanged so undo entries that no longer apply fail soft instead of * corrupting state. */ export function applyOpToDeck(deck: Deck, op: PatchDeckOp): Deck { switch (op.op) { case "patch-slide": { let changed = false; const slides = deck.slides.map((s) => { if (s.id !== op.slideId) return s; changed = true; return { ...s, ...op.fields }; }); if (!changed) return deck; // slide concurrently deleted — skip return { ...deck, slides, updatedAt: new Date().toISOString() }; } case "delete-slide": { const slides = deck.slides.filter((s) => s.id !== op.slideId); if (slides.length === deck.slides.length) return deck; // already gone // NOTE: unlike the user-facing `deleteSlide` handler and the server merge, // undo/redo application does NOT inject a fallback blank slide when the // deck empties out. Undo must restore the EXACT prior state — if the deck // was legitimately empty before an add-slide (e.g. a freshly reloaded // empty deck), undoing that add must return it to empty, not to a // spurious blank slide. return { ...deck, slides, updatedAt: new Date().toISOString() }; } case "reorder-slides": { const byId = new Map(deck.slides.map((s) => [s.id, s])); const reordered: Slide[] = []; for (const id of op.orderedIds) { const slide = byId.get(id); if (slide) reordered.push(slide); } // Preserve slides not named in orderedIds (concurrent adds) at the end. const named = new Set(op.orderedIds); for (const s of deck.slides) { if (!named.has(s.id)) reordered.push(s); } return { ...deck, slides: reordered, updatedAt: new Date().toISOString(), }; } case "add-slide": { if (deck.slides.some((s) => s.id === op.slideId)) return deck; // idempotent const newSlide: Slide = { id: op.slideId, content: op.fields.content, notes: op.fields.notes ?? "", layout: (op.fields.layout as SlideLayout) ?? "content", ...(op.fields.background !== undefined ? { background: op.fields.background } : {}), }; const slides = [...deck.slides]; const afterIdx = op.afterSlideId ? slides.findIndex((s) => s.id === op.afterSlideId) : -1; if (afterIdx !== -1) slides.splice(afterIdx + 1, 0, newSlide); else slides.push(newSlide); return { ...deck, slides, updatedAt: new Date().toISOString() }; } case "patch-deck-fields": { return { ...deck, ...op.fields, updatedAt: new Date().toISOString(), } as Deck; } } } export function applyUndoOpToDecks(decks: Deck[], op: DeckUndoOp): Deck[] { switch (op.op) { case "delete-deck": return decks.filter((deck) => deck.id !== op.deckId); case "restore-deck": { const nextDeck = op.deck; const existingIndex = decks.findIndex((deck) => deck.id === op.deckId); if (existingIndex >= 0) { const next = [...decks]; next[existingIndex] = nextDeck; return next; } const next = [...decks]; const index = typeof op.index === "number" && op.index >= 0 ? Math.min(op.index, next.length) : next.length; next.splice(index, 0, nextDeck); return next; } case "replace-deck": { const existingIndex = decks.findIndex((deck) => deck.id === op.deckId); if (existingIndex < 0) return [...decks, op.deck]; const next = [...decks]; next[existingIndex] = op.deck; return next; } default: { const idx = decks.findIndex((deck) => deck.id === op.deckId); if (idx < 0) return decks; const { deckId: _deckId, ...granular } = op; void _deckId; const updated = applyOpToDeck(decks[idx], granular); if (updated === decks[idx]) return decks; const next = [...decks]; next[idx] = updated; return next; } } } /** * Compare deck content for remote-sync undo. Ignores `updatedAt` so a * metadata-only refresh does not create a no-op undo entry. */ export function deckContentSignature(deck: Deck): string { const { updatedAt: _updatedAt, ...rest } = deck; void _updatedAt; return JSON.stringify(rest); } /** * Build the inverse of a granular op given the deck state BEFORE the op was * applied. Returns an array of ops to apply (usually one, occasionally two) or * `null` when the op has no meaningful inverse (e.g. a no-op patch) so the * caller skips pushing an undo entry. */ export function deriveInverseOp( before: Deck, op: PatchDeckOp, ): PatchDeckOp[] | null { switch (op.op) { case "patch-slide": { const prior = before.slides.find((s) => s.id === op.slideId); if (!prior) return null; // slide didn't exist before — nothing to restore const priorFields: Partial> = {}; for (const key of Object.keys(op.fields) as (keyof Omit)[]) { // Capture the prior value for every field this op touches, so undo // restores exactly what changed (including clearing fields back to // undefined). (priorFields as Record)[key] = prior[key]; } return [{ op: "patch-slide", slideId: op.slideId, fields: priorFields }]; } case "delete-slide": { const prior = before.slides.find((s) => s.id === op.slideId); if (!prior) return null; const idx = before.slides.findIndex((s) => s.id === op.slideId); const afterSlideId = idx > 0 ? before.slides[idx - 1]?.id : undefined; // Re-add the deleted slide with its full prior content, then reorder to // the exact prior order. The add-slide op alone can only express "after // slide X" or "append", so it cannot restore a slide to the HEAD of the // deck; the follow-up reorder guarantees exact position regardless. return [ { op: "add-slide", slideId: prior.id, afterSlideId, fields: { content: prior.content, notes: prior.notes, layout: prior.layout, ...(prior.background !== undefined ? { background: prior.background } : {}), }, }, { op: "reorder-slides", orderedIds: before.slides.map((s) => s.id), }, ]; } case "add-slide": { // Inverse of adding a slide is deleting it. return [ { op: "delete-slide", slideId: op.slideId, ...(before.slides.length === 0 ? { allowEmpty: true } : {}), }, ]; } case "reorder-slides": { // Inverse reorder = the order the slides were in before. return [ { op: "reorder-slides", orderedIds: before.slides.map((s) => s.id) }, ]; } case "patch-deck-fields": { const priorFields: Record = {}; const beforeRecord = before as unknown as Record; for (const key of Object.keys(op.fields)) { priorFields[key] = beforeRecord[key]; } return [ { op: "patch-deck-fields", fields: priorFields as PatchDeckFields, }, ]; } } } /** * Fetch the deck list. Returns `null` on any failure (network error, non-2xx * response) so callers can distinguish "authoritative empty list" from * "couldn't reach the server" — wiping local state on a transient failure * kicks the user out of the editor and shows the "Create your first deck" * empty state, even though their decks still exist on the server. The 200/[] * case still means the user has no decks and is returned as `[]`. */ async function fetchDecksFromAPI(): Promise { try { const result = await callAction( "list-decks", { includeSlides: "true" }, { method: "GET" }, ); if (!Array.isArray(result?.decks)) { console.warn("Failed to fetch decks: invalid action response"); return null; } return result.decks .map((deck) => normalizeActionDeck(deck)) .filter((deck): deck is Deck => deck !== null); } catch (err) { console.error("Failed to fetch decks:", err); return null; } } /** * Fetch a minimal id-only deck listing (`light: "true"`) for cheap add/remove * diffing. Never downloads deck bodies — see `list-decks.ts`. Returns `null` * on any failure so callers can skip the diff instead of wiping local state. */ async function fetchDeckListLightFromAPI(): Promise<{ id: string }[] | null> { try { const result = await callAction( "list-decks", { light: "true" }, { method: "GET" }, ); if (!Array.isArray(result?.decks)) { console.warn("Failed to fetch deck list: invalid action response"); return null; } return result.decks .filter( (deck): deck is { id: string } => !!deck && typeof deck === "object" && typeof (deck as { id?: unknown }).id === "string", ) .map((deck) => ({ id: deck.id })); } catch (err) { console.error("Failed to fetch deck list:", err); return null; } } async function fetchDeckFromAPI(id: string): Promise { try { const result = await callAction( "get-deck", { id }, { method: "GET" }, ); return normalizeActionDeck(result); } catch (err) { console.error(`Failed to fetch deck ${id}:`, err); return null; } } export function deckIdFromPathname(pathname: string): string | null { const match = pathname.match(/\/deck\/([^/?#]+)/); if (!match?.[1]) return null; try { return decodeURIComponent(match[1]); } catch { return match[1]; } } function currentOpenDeckIdFromWindow(): string | null { if (typeof window === "undefined") return null; return deckIdFromPathname(window.location.pathname); } export async function includeOpenDeckIfMissing( decks: Deck[], openDeckId: string | null, fetchById: (id: string) => Promise = fetchDeckFromAPI, ): Promise { if (!openDeckId || decks.some((deck) => deck.id === openDeckId)) { return decks; } const directDeck = await fetchById(openDeckId); return directDeck ? [...decks, directDeck] : decks; } async function fetchDecksForCurrentRoute(): Promise { const currentOpenDeckId = currentOpenDeckIdFromWindow(); const loaded = await fetchDecksFromAPI(); if (loaded !== null) { return includeOpenDeckIfMissing(loaded, currentOpenDeckId); } if (!currentOpenDeckId) return null; const directDeck = await fetchDeckFromAPI(currentOpenDeckId); return directDeck ? [directDeck] : null; } async function deleteDeckFromAPI(id: string): Promise { await callAction("delete-deck", { id }, { method: "DELETE" }); } async function createDeckOnAPI(deck: Deck): Promise { // `callAction` bounds the request so a stalled create response can't leave // the deck id in `pendingCreateIdsRef` forever (cleared only in the caller's // `.finally`, which needs this promise to settle). A wedged pending-create id // would otherwise suppress the open-deck refetch just like a wedged save. await callAction("add-deck", { deck: deckPayload(deck) }); } export function changedDeckIds(before: Deck[], after: Deck[]): string[] { const beforeById = new Map(before.map((deck) => [deck.id, deck])); const changed: string[] = []; for (const deck of after) { const previous = beforeById.get(deck.id); if (!previous || JSON.stringify(previous) !== JSON.stringify(deck)) { changed.push(deck.id); } } return changed; } export function hasUncommittedDeckChanges( deckId: string, dirtyDeckIds: Set, ): boolean { return ( dirtyDeckIds.has(deckId) || pendingSaves.has(deckId) || inFlightSaves.has(deckId) ); } /** * Additive, content-preserving reconcile of a server deck snapshot onto the * local copy — used when the open deck has uncommitted local edits, where a * wholesale adopt would clobber the user's in-progress typing. * * The concern the "uncommitted changes" guard originally addressed (don't * overwrite local edits with slightly-stale server state) is legitimate for * slide BODIES, but it must not make the client permanently blind to the * agent ADDING slides — that is the production staleness bug. So we split the * two concerns: * - never overwrite the content of a slide that exists locally, and * - never drop a local-only slide (an unsaved local add), but * - always surface server slides that are missing locally (agent additions), * positioned to follow the server's ordering. * * Removals and content changes to slides that exist on both sides are left to * the clean-deck path (`applyRemoteDeckUpdate`), which runs once local edits * settle. This merge is intentionally conservative: it can only ADD slides, so * it can never destroy local work, yet it always heals an empty/stale rail. * * Returns the same `local` reference when nothing was added, so callers can * cheaply detect "no change". */ export function mergeServerAddedSlides(local: Deck, server: Deck): Deck { const localIds = new Set(local.slides.map((s) => s.id)); const additions = server.slides.filter((s) => !localIds.has(s.id)); if (additions.length === 0) return local; // Walk the server order, emitting local slides with their local (possibly // dirty) content and inserting server-only additions in place. Any local // slide not present on the server (an unsaved local add) is carried over at // the end so we never drop unsaved local work. const localById = new Map(local.slides.map((s) => [s.id, s])); const emitted = new Set(); const merged: Slide[] = []; for (const s of server.slides) { const localSlide = localById.get(s.id); merged.push(localSlide ?? s); emitted.add(s.id); } for (const s of local.slides) { if (!emitted.has(s.id)) { merged.push(s); emitted.add(s.id); } } // Keep local scalar fields (title/tweaks/etc. may be locally edited); only // the slide set is reconciled here. return { ...local, slides: merged }; } export const defaultSlideContent: Record = { title: `
Deck
Presentation Title
Your Name
Date
`, content: `
SECTION
Slide Title
First point
Second point
Third point
`, "two-column": `
SECTION
Left Column
Content for the left side
Right column visual
`, section: `
Section Title
`, image: `
Image Slide Title
Image description
`, statement: `
Bold statement or key message goes here
Supporting context or subtitle text
`, "full-image": `
Full-bleed image or screenshot
`, blank: `
`, }; export function DeckProvider({ children }: { children: ReactNode }) { const { data: org } = useOrg(); const [decks, setDecks] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); const decksRef = useRef([]); // Per-user inverse-op undo/redo. `canUndo`/`canRedo` are React state kept in // sync with the controller via its onChange callback. The controller and its // apply path are wired below once `decks`/enqueue are in scope. const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const undoControllerRef = useRef | null>( null, ); // Track when external (SSE) updates happen so the save effect doesn't echo them back const lastExternalUpdateRef = useRef(0); // Track client-created decks that haven't been confirmed on the server yet. // Prevents the poll from wiping optimistic decks before their POST lands. const pendingCreateIdsRef = useRef>(new Set()); const pendingCreatePromisesRef = useRef>>( new Map(), ); const pendingDuplicateSourceIdsRef = useRef>(new Set()); const dirtyDeckIdsRef = useRef>(new Set()); const deckBaselineRequestIdRef = useRef(0); // True only while the SSE channel is actually open. Stays false when SSE is // never started (embed auth), so the poll keeps its fast intervals there. const liveChannelConnectedRef = useRef(false); // Lets the SSE effect wake the poll the moment the live channel drops. const pollNowRef = useRef<() => void>(() => {}); // Bumped on every local deck create. A deck-list snapshot fetched before a // deck's bump cannot prove that deck is absent server-side, so any // reconciliation against such a snapshot must leave it alone. Keyed by id and // deliberately outliving `pendingCreateIdsRef`, which clears the moment the // create resolves — often before the older list response lands. const localCreateSeqRef = useRef(0); const localCreateSeqByIdRef = useRef>(new Map()); const noteLocalCreate = useCallback((deckId: string) => { localCreateSeqRef.current += 1; localCreateSeqByIdRef.current.set(deckId, localCreateSeqRef.current); }, []); /** True when `deckId` is local state a snapshot taken at `seq` cannot refute. */ const isNewerThanSnapshot = useCallback((deckId: string, seq: number) => { return ( pendingCreateIdsRef.current.has(deckId) || (localCreateSeqByIdRef.current.get(deckId) ?? 0) >= seq ); }, []); const markDeckDirty = useCallback((deckId: string) => { lastExternalUpdateRef.current = 0; dirtyDeckIdsRef.current.add(deckId); }, []); const deleteDeckAfterPendingCreate = useCallback( (deckId: string, onFailure?: () => void) => { const pendingCreate = pendingCreatePromisesRef.current.get(deckId); const deletion = pendingCreate ? pendingCreate.then( () => deleteDeckFromAPI(deckId), () => undefined, ) : deleteDeckFromAPI(deckId); void deletion.catch((err) => { console.error(`Failed to delete deck ${deckId}:`, err); onFailure?.(); }); }, [], ); // Plain local decks update. Undo entries are recorded explicitly by each // mutation via `recordUndo` (inverse ops), so this no longer snapshots the // whole decks array the way the old `setDecksWithHistory` did. const setDecksLocal = useCallback((updater: (prev: Deck[]) => Deck[]) => { setDecks(updater); }, []); useEffect(() => { decksRef.current = decks; }, [decks]); // ── Inverse-op undo controller ──────────────────────────────────────────── // Applying an undo/redo entry runs each tagged op through the SAME optimistic // local update + granular persist path as a normal edit. Because we only ever // send granular ops (never full-replace), undo/redo can never clobber a // concurrent edit to a different slide by another human or the agent. Entries // that no longer apply (e.g. the slide was deleted remotely) fail soft: // applyOpToDeck returns the deck unchanged and the granular server merge // ignores the missing target. if (!undoControllerRef.current) { undoControllerRef.current = createLocalOpUndoController({ apply: (ops) => { // Apply all ops to local state in one pass, then persist each. setDecks((prev) => { let next = prev; for (const op of ops) { next = applyUndoOpToDecks(next, op); } return next; }); for (const op of ops) { markDeckDirty(op.deckId); if (op.op === "delete-deck") { discardPendingDeckOps(op.deckId); deleteDeckAfterPendingCreate(op.deckId); } else if (op.op === "restore-deck" || op.op === "replace-deck") { enqueueDeckOp(op.deckId, { op: "full-replace", deck: op.deck }); } else { const { deckId, ...granular } = op; enqueueDeckOp(deckId, granular); } } }, onChange: () => { const c = undoControllerRef.current; setCanUndo(c ? c.canUndo() : false); setCanRedo(c ? c.canRedo() : false); }, }); } /** * Record an undo entry for a just-applied local mutation. `before` is the * deck state prior to the mutation (for inverse derivation); `redoOp` is the * forward op that was applied. Same `coalesceKey` within the controller's * window merges bursts (e.g. rapid text edits to one slide). */ const recordUndo = useCallback( ( before: Deck, redoOp: PatchDeckOp, opts?: { label?: string; coalesceKey?: string }, ) => { const inverseOps = deriveInverseOp(before, redoOp); if (!inverseOps || inverseOps.length === 0) return; const entry: LocalOpUndoEntry = { undo: inverseOps.map((o) => ({ deckId: before.id, ...o })), redo: [{ deckId: before.id, ...redoOp }], label: opts?.label, coalesceKey: opts?.coalesceKey, }; undoControllerRef.current?.push(entry); }, [], ); /** * Apply a remote deck snapshot (agent / collaborator via SSE or poll) and * record a replace-deck undo entry when content actually changed. Without * this, chat-driven edits land in the editor with Undo disabled. */ const applyRemoteDeckUpdate = useCallback( (updated: Deck, label = "Agent edit") => { const before = decksRef.current.find((d) => d.id === updated.id); if ( before && deckContentSignature(before) !== deckContentSignature(updated) ) { undoControllerRef.current?.push({ undo: [{ op: "replace-deck", deckId: updated.id, deck: before }], redo: [{ op: "replace-deck", deckId: updated.id, deck: updated }], label, }); } setDecks((prev) => { const idx = prev.findIndex((d) => d.id === updated.id); if (idx >= 0) { const next = [...prev]; next[idx] = updated; return next; } return [...prev, updated]; }); }, [], ); // Re-fetch the deck list and diff it against local state (added/removed // decks). Shared by the fallback poll and the SSE resync-on-reconnect path // below so both pull from one implementation of "what changed". // // Uses the `light` (id-only) listing — this runs every 15s and previously // downloaded every deck's full slide JSON just to diff ids, even though // existing decks' content was thrown away unused. Only genuinely NEW decks // (rare — usually zero per poll) get a follow-up full fetch so DeckCard can // still render an immediate preview for them. const refetchDeckListIfChanged = useCallback(async () => { const createSeqAtRequest = localCreateSeqRef.current; const fresh = await fetchDeckListLightFromAPI(); // A null result means the fetch failed (network error or non-2xx). Skip // the diff so we don't wipe local state on a transient failure. if (fresh === null) return; setLoadError(false); const currentDecks = decksRef.current; const currentIds = new Set(currentDecks.map((d) => d.id)); const freshIds = new Set(fresh.map((d) => d.id)); // Check if deck list changed (added or removed). Decks this client created // after the snapshot was taken are absent from the response because the // snapshot predates them, not because the server dropped them — treating // that as a removal is what wiped a just-created deck back to the "Create // your first deck" empty state. const addedIds = fresh .filter((d) => !currentIds.has(d.id)) .map((d) => d.id); const removed = currentDecks.filter( (d) => !freshIds.has(d.id) && !isNewerThanSnapshot(d.id, createSeqAtRequest), ); for (const id of freshIds) localCreateSeqByIdRef.current.delete(id); if (addedIds.length === 0 && removed.length === 0) return; const addedDecks = ( await Promise.all(addedIds.map((id) => fetchDeckFromAPI(id))) ).filter((d): d is Deck => d !== null); lastExternalUpdateRef.current = Date.now(); const removedIds = new Set(removed.map((d) => d.id)); setDecks((prev) => { const prevIds = new Set(prev.map((d) => d.id)); // Drop only the decks this diff actually judged removed — a deck added to // `prev` after the snapshot was taken must survive. let next = prev.filter((d) => !removedIds.has(d.id)); // Only add decks that aren't already in prev (prevents duplicates when // the closure's deck snapshot is stale compared to `prev`). for (const a of addedDecks) { if (!prevIds.has(a.id)) next = [...next, a]; } return next; }); }, [isNewerThanSnapshot]); // Re-fetch the currently-open deck's full slide data and reconcile it. // // We ALWAYS fetch — never gate on pending-create or uncommitted-edits state. // Gating the fetch was the liveness bug: a wedged `pendingSaves` / // `inFlightSaves` / `pendingCreateIdsRef` entry (or a legitimately dirty // deck) would make the editor permanently blind to agent-added slides. // // How we APPLY the result depends on whether there are local edits to // protect: // - Clean deck → adopt the server snapshot wholesale (handles content // changes, removals, and reorders too), exactly as before. // - Dirty deck / unsaved local create → additive merge only: surface // agent-added slides without ever overwriting or dropping local slides. const refetchOpenDeckIfChanged = useCallback( async (currentOpenId: string) => { const serverDeck = await fetchDeckFromAPI(currentOpenId); // Null means 404 (row not created yet), a transient failure, or a // still-pending create — nothing authoritative to reconcile. if (!serverDeck) return; const clientDeck = decksRef.current.find((d) => d.id === currentOpenId); const hasLocalEdits = pendingCreateIdsRef.current.has(currentOpenId) || hasUncommittedDeckChanges(currentOpenId, dirtyDeckIdsRef.current); if (hasLocalEdits && clientDeck) { // Content-preserving: only ADD server slides missing locally. const merged = mergeServerAddedSlides(clientDeck, serverDeck); if (merged === clientDeck) return; // nothing new to surface lastExternalUpdateRef.current = Date.now(); setDecks((prev) => { const idx = prev.findIndex((d) => d.id === currentOpenId); if (idx < 0) return prev; const next = [...prev]; next[idx] = merged; return next; }); return; } const changed = !clientDeck || clientDeck.updatedAt !== serverDeck.updatedAt || clientDeck.slides.length !== serverDeck.slides.length; if (!changed) return; lastExternalUpdateRef.current = Date.now(); applyRemoteDeckUpdate(serverDeck); }, [applyRemoteDeckUpdate], ); /** * Full resync of authoritative deck/slide state from the server. The SSE * channel (`notifyClients` server-side) is fire-and-forget to whatever * connections are live at broadcast time — there is no backlog or replay, * so any event emitted while this tab was disconnected is gone forever. * Call this whenever the SSE connection (re)establishes after a drop so * agent writes made during the gap show up without requiring a full page * reload. */ const resyncDeckState = useCallback(async () => { try { await refetchDeckListIfChanged(); } catch {} const currentOpenId = currentOpenDeckIdFromWindow(); if (!currentOpenId) return; try { await refetchOpenDeckIfChanged(currentOpenId); } catch {} }, [refetchDeckListIfChanged, refetchOpenDeckIfChanged]); const resetDeckBaseline = useCallback( (nextDecks: Deck[], createSeqAtRequest: number) => { const nextIds = new Set(nextDecks.map((d) => d.id)); setDecks((prev) => { // A wholesale replace still can't discard state the snapshot never saw: // a deck created here after the fetch started is missing from // `nextDecks` because the response predates it. const preserved = prev.filter( (d) => !nextIds.has(d.id) && isNewerThanSnapshot(d.id, createSeqAtRequest), ); return preserved.length === 0 ? nextDecks : [...nextDecks, ...preserved]; }); for (const id of nextIds) localCreateSeqByIdRef.current.delete(id); // A baseline reset (initial mount, route change, or access reload) starts a // fresh undo timeline. Note: this is NOT the SSE/poll "remote update" path — // those call setDecks directly and intentionally leave the undo stack // intact so a collaborator's edit doesn't wipe your local undo history. undoControllerRef.current?.clear(); }, [isNewerThanSnapshot], ); const reloadDecks = useCallback(async () => { const requestId = ++deckBaselineRequestIdRef.current; const createSeqAtRequest = localCreateSeqRef.current; const requestedOpenDeckId = currentOpenDeckIdFromWindow(); const loaded = await fetchDecksForCurrentRoute(); if ( requestId !== deckBaselineRequestIdRef.current || requestedOpenDeckId !== currentOpenDeckIdFromWindow() || loaded === null ) { if (requestId === deckBaselineRequestIdRef.current) { setLoadError(loaded === null); } return; } lastExternalUpdateRef.current = Date.now(); resetDeckBaseline(loaded, createSeqAtRequest); setLoadError(false); }, [resetDeckBaseline]); // Load decks from API on mount useEffect(() => { const requestId = ++deckBaselineRequestIdRef.current; const createSeqAtRequest = localCreateSeqRef.current; const requestedOpenDeckId = currentOpenDeckIdFromWindow(); fetchDecksForCurrentRoute().then(async (loaded) => { if ( requestId !== deckBaselineRequestIdRef.current || requestedOpenDeckId !== currentOpenDeckIdFromWindow() ) { setLoading(false); return; } // Initial fetch failed — start empty so the UI can render. The fallback // poll will retry shortly; until then `decks` stays empty without // triggering the save effect (lastExternalUpdateRef is bumped). const initial = loaded ?? []; lastExternalUpdateRef.current = Date.now(); // Don't save initial load back resetDeckBaseline(initial, createSeqAtRequest); setLoadError(loaded === null); setLoading(false); }); }, [resetDeckBaseline]); // Switching orgs re-scopes list-decks server-side but leaves this context's // in-memory list untouched, so the previous org's decks linger. Reload when // the org id actually changes; skip the first observed id so we don't double // up on the mount fetch above. const lastOrgIdRef = useRef(undefined); useEffect(() => { const orgId = org?.orgId ?? null; if (lastOrgIdRef.current === undefined) { lastOrgIdRef.current = orgId; return; } if (lastOrgIdRef.current === orgId) return; lastOrgIdRef.current = orgId; void reloadDecks(); }, [org?.orgId, reloadDecks]); // Fallback polling for deck list + open-deck changes. SSE is the primary // path; this catches agent/db writes that bypass it without hammering idle // editor pages. useEffect(() => { if (loading) return; let stopped = false; let timer: ReturnType | null = null; let lastListFetchAt = 0; const readOpenDeckId = (): string | null => { if (typeof window === "undefined") return null; return deckIdFromPathname(window.location.pathname); }; const isHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden"; const schedule = () => { if (stopped || isHidden()) return; timer = setTimeout( poll, fallbackPollIntervalMs({ liveChannelConnected: liveChannelConnectedRef.current, hasOpenDeck: Boolean(readOpenDeckId()), }), ); }; async function poll() { if (stopped || isHidden()) return; const now = Date.now(); const currentOpenId = readOpenDeckId(); try { if ( !currentOpenId || now - lastListFetchAt >= DECK_LIST_FALLBACK_POLL_MS ) { lastListFetchAt = now; // A failed fetch (network error or non-2xx) is swallowed inside // refetchDeckListIfChanged — skip the diff so we don't wipe local // state on a transient failure, otherwise the user's open deck // disappears and they're bounced back to the empty "Create your // first deck" screen until the next poll succeeds. await refetchDeckListIfChanged(); } // Also re-fetch the currently-open deck so agent-added slides show up. // The list endpoint may not include full slide contents, and SSE can // miss events if the client reconnects between broadcasts. if (currentOpenId) { try { await refetchOpenDeckIfChanged(currentOpenId); } catch {} } } catch {} schedule(); } const pollNow = () => { if (isHidden()) return; if (timer) { clearTimeout(timer); timer = null; } void poll(); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { pollNow(); } else if (timer) { clearTimeout(timer); timer = null; } }; void poll(); pollNowRef.current = pollNow; window.addEventListener("focus", pollNow); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { stopped = true; if (timer) clearTimeout(timer); if (pollNowRef.current === pollNow) pollNowRef.current = () => {}; window.removeEventListener("focus", pollNow); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [refetchDeckListIfChanged, refetchOpenDeckIfChanged, loading]); // The dirty-deck set is now only used as a sentinel that "something changed // for this deck". Ops are enqueued directly in each mutation handler below; // this effect is kept as a safety net that drains any dirty decks that did // NOT go through the granular path (e.g. future callers, undo/redo which // already enqueue full-replace ops, or edge cases we haven't anticipated). useEffect(() => { if (loading) return; if (Date.now() - lastExternalUpdateRef.current < 2000) return; const dirtyIds = Array.from(dirtyDeckIdsRef.current); if (dirtyIds.length === 0) return; for (const id of dirtyIds) { dirtyDeckIdsRef.current.delete(id); // Only fall back to full-replace if no granular ops were enqueued // for this deck (they handle the actual save). if (!pendingOpsQueue.has(id) && !pendingSaves.has(id)) { const deck = decks.find((d) => d.id === id); if (!deck) continue; saveDeckToAPI(deck); } } }, [decks, loading]); // Listen for deck changes via SSE (so agent edits show up in real-time). // // EventSource auto-reconnects on its own ONLY for network-level drops // (readyState stays CONNECTING while it retries). A non-2xx HTTP response // — e.g. a transient 503 during a cold start — is FATAL per spec: the // browser sets readyState CLOSED and never retries. Without our own // reconnect logic, a single 503 permanently kills live updates for the // rest of the tab's life (the rail goes stale until a manual reload). // // We reconnect manually with bounded exponential backoff, and because // notifyClients() on the server has no backlog/replay (fire-and-forget to // whatever connections are live at broadcast time — see // server/handlers/decks.ts), every reconnect after the first triggers a // full resync via resyncDeckState() so agent writes made during the gap // aren't silently lost. useEffect(() => { if (isEmbedAuthActive()) return; let stopped = false; let es: EventSource | null = null; let reconnectTimer: ReturnType | null = null; let retryCount = 0; let hasConnectedOnce = false; const isHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden"; const clearReconnectTimer = () => { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } }; const scheduleReconnect = () => { if (stopped || reconnectTimer || isHidden()) return; const delay = Math.min( SSE_RECONNECT_BASE_MS * 2 ** retryCount, SSE_RECONNECT_MAX_MS, ); retryCount += 1; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); }; const handleMessage = async (event: MessageEvent) => { try { const data = JSON.parse(event.data); if (data.type === "deck-deleted" && data.deckId) { lastExternalUpdateRef.current = Date.now(); setDecks((prev) => prev.filter((d) => d.id !== data.deckId)); } else if (data.type === "deck-changed" && data.deckId) { // Skip if a save for this deck is pending or in flight — this // event is most likely the echo of our own write and the server // copy may be a few hundred ms behind what the user just typed. // Polling and the next save's response will bring the canonical // state once the local burst settles. if (hasUncommittedDeckChanges(data.deckId, dirtyDeckIdsRef.current)) { return; } // Refetch the changed deck from the shared action surface. const updated = await fetchDeckFromAPI(data.deckId); if (!updated) return; lastExternalUpdateRef.current = Date.now(); // Suppress save-back applyRemoteDeckUpdate(updated); } } catch {} }; const connect = () => { if (stopped || isHidden()) return; liveChannelConnectedRef.current = false; // Never leak the previous connection. if (es) { es.close(); es = null; } // request-storm-allow: one deck-scoped SSE with backoff and unmount cleanup carries payloads sync events omit. const next = new EventSource(`${appBasePath()}/api/decks/events`); es = next; next.onmessage = handleMessage; next.onopen = () => { retryCount = 0; liveChannelConnectedRef.current = true; // Every reconnect after the first can have missed broadcasts made // while we were disconnected — the SSE channel has no backlog, so // resync authoritative state instead of trusting the stream alone // to have caught us up. if (hasConnectedOnce) { void resyncDeckState(); } hasConnectedOnce = true; }; next.onerror = () => { if (es !== next) return; // Whether the browser is retrying (CONNECTING) or gave up (CLOSED), // the live channel is not delivering — hand liveness back to the // fallback poll instead of letting it idle at 60s. Only the // connected→dropped transition wakes the poll, so a stream of retry // errors during a long outage doesn't turn into a poll per error. const wasConnected = liveChannelConnectedRef.current; liveChannelConnectedRef.current = false; if (wasConnected) pollNowRef.current(); if (next.readyState === EventSource.CLOSED) { // Fatal per spec (non-2xx status, bad content-type, etc.) — the // browser will not retry on its own. Reconnect ourselves. next.close(); es = null; scheduleReconnect(); } // readyState === CONNECTING means the browser is already retrying a // network-level drop on its own; onopen above resyncs once that // succeeds. }; }; const handleVisibilityChange = () => { if (isHidden()) { clearReconnectTimer(); return; } retryCount = 0; if (!es || es.readyState === EventSource.CLOSED) { connect(); } }; connect(); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { stopped = true; liveChannelConnectedRef.current = false; clearReconnectTimer(); document.removeEventListener("visibilitychange", handleVisibilityChange); if (es) { es.close(); es = null; } }; }, [applyRemoteDeckUpdate, resyncDeckState]); // Flush pending (debounced) saves before the tab is hidden or unloaded so the // last ~500ms of edits aren't lost on close/navigation. `pagehide` is the // reliable unload signal on modern browsers (incl. bfcache); we also flush on // `visibilitychange(hidden)` which fires on mobile tab-switch / app-background // where `pagehide` may not. useEffect(() => { const onHidden = () => { if (document.visibilityState === "hidden") flushPendingSaves(); }; const onPageHide = () => flushPendingSaves(); document.addEventListener("visibilitychange", onHidden); window.addEventListener("pagehide", onPageHide); return () => { document.removeEventListener("visibilitychange", onHidden); window.removeEventListener("pagehide", onPageHide); }; }, []); const undo = useCallback(() => { void undoControllerRef.current?.undo(); }, []); const redo = useCallback(() => { void undoControllerRef.current?.redo(); }, []); // Keyboard shortcuts for undo/redo useEffect(() => { const handleKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; // Don't intercept undo/redo when typing in an input, textarea, or // contenteditable (TipTap inline editor) — let those handle it themselves. const isTyping = target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.isContentEditable; if ((e.metaKey || e.ctrlKey) && e.key === "z") { if (isTyping) return; e.preventDefault(); if (e.shiftKey) { redo(); } else { undo(); } } if ((e.metaKey || e.ctrlKey) && e.key === "y") { if (isTyping) return; e.preventDefault(); redo(); } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [undo, redo]); const createDeck = useCallback( ( title?: string, options?: { noDefaultSlides?: boolean; designSystemId?: string | null }, ): Deck => { const insertIndex = decksRef.current.length; const newDeck: Deck = { id: nanoid(10), title: title || "Untitled Deck", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdByMe: true, designSystemId: options?.designSystemId ?? undefined, slides: options?.noDefaultSlides ? [] : [ { id: nanoid(8), content: defaultSlideContent.title, notes: "", layout: "title", background: "bg-[#000000]", }, { id: nanoid(8), content: defaultSlideContent.content, notes: "", layout: "content", background: "bg-[#000000]", }, ], }; // Save to API immediately (not debounced). Track as pending so the // poll doesn't wipe the optimistic deck before the POST completes. pendingCreateIdsRef.current.add(newDeck.id); noteLocalCreate(newDeck.id); const createPromise = createDeckOnAPI(newDeck); pendingCreatePromisesRef.current.set(newDeck.id, createPromise); createPromise .catch((err) => { console.error(`Failed to create deck ${newDeck.id}:`, err); }) .finally(() => { pendingCreateIdsRef.current.delete(newDeck.id); if ( pendingCreatePromisesRef.current.get(newDeck.id) === createPromise ) { pendingCreatePromisesRef.current.delete(newDeck.id); } }); setDecksLocal((prev) => [...prev, newDeck]); undoControllerRef.current?.push({ undo: [{ op: "delete-deck", deckId: newDeck.id }], redo: [ { op: "restore-deck", deckId: newDeck.id, deck: newDeck, index: insertIndex, }, ], label: "Create deck", }); return newDeck; }, [noteLocalCreate, setDecksLocal], ); const ensureDeckPersisted = useCallback(async (id: string) => { const pendingCreate = pendingCreatePromisesRef.current.get(id); if (pendingCreate) { try { await pendingCreate; return true; } catch { return false; } } return (await fetchDeckFromAPI(id)) !== null; }, []); const duplicateDeck = useCallback( (sourceDeckId: string, newId: string, title?: string): Deck | null => { if (pendingDuplicateSourceIdsRef.current.has(sourceDeckId)) return null; const source = decks.find((d) => d.id === sourceDeckId); if (!source) return null; const now = new Date().toISOString(); const newTitle = title || `Copy of ${source.title}`; const insertIndex = decksRef.current.length; // Re-id slides so optimistic edits to the copy don't collide with the // original. The server does the same thing — these client ids will be // replaced by server-generated ones once the duplicate action lands and // the next poll/SSE refresh syncs the row. const optimistic: Deck = { ...(JSON.parse(JSON.stringify(source)) as Deck), id: newId, title: newTitle, createdAt: now, updatedAt: now, // Visibility/share state doesn't carry over to a fresh copy — server // creates the new row owned by the current user, private by default. visibility: "private", createdByMe: true, shareToken: undefined, }; optimistic.slides = optimistic.slides.map((s) => ({ ...s, id: nanoid(8), })); // Track as pending so the poll doesn't wipe the optimistic deck before // the duplicate-deck action's INSERT lands. pendingCreateIdsRef.current.add(newId); noteLocalCreate(newId); pendingDuplicateSourceIdsRef.current.add(sourceDeckId); // Fire the action in the background. On error, roll back. const duplicatePromise = callAction( "duplicate-deck", { deckId: sourceDeckId, newId, title, }, ).then(() => undefined); pendingCreatePromisesRef.current.set(newId, duplicatePromise); duplicatePromise .catch((err) => { console.error("Duplicate failed:", err); // Roll back: drop the optimistic deck from local state. setDecks((prev) => prev.filter((d) => d.id !== newId)); }) .finally(() => { pendingCreateIdsRef.current.delete(newId); if ( pendingCreatePromisesRef.current.get(newId) === duplicatePromise ) { pendingCreatePromisesRef.current.delete(newId); } pendingDuplicateSourceIdsRef.current.delete(sourceDeckId); }); setDecksLocal((prev) => [...prev, optimistic]); undoControllerRef.current?.push({ undo: [{ op: "delete-deck", deckId: optimistic.id }], redo: [ { op: "restore-deck", deckId: optimistic.id, deck: optimistic, index: insertIndex, }, ], label: "Duplicate deck", }); return optimistic; }, [decks, noteLocalCreate, setDecksLocal], ); const deleteDeck = useCallback( (id: string) => { const beforeDeck = decksRef.current.find((deck) => deck.id === id); const beforeIndex = decksRef.current.findIndex((deck) => deck.id === id); discardPendingDeckOps(id); deleteDeckAfterPendingCreate(id, () => { if (!beforeDeck) return; setDecks((prev) => { if (prev.some((deck) => deck.id === id)) return prev; const next = [...prev]; next.splice( Math.max(0, Math.min(beforeIndex, next.length)), 0, beforeDeck, ); return next; }); }); setDecksLocal((prev) => prev.filter((d) => d.id !== id)); if (beforeDeck) { undoControllerRef.current?.push({ undo: [ { op: "restore-deck", deckId: id, deck: beforeDeck, index: beforeIndex, }, ], redo: [{ op: "delete-deck", deckId: id }], label: "Delete deck", }); } }, [deleteDeckAfterPendingCreate, setDecksLocal], ); const updateDeck = useCallback( (id: string, updates: Partial>) => { // Clear the external-update suppression window so a rename/update that // happens within 2s of page load (or an SSE event) is not silently dropped. markDeckDirty(id); const before = decksRef.current.find((d) => d.id === id); setDecks((prev) => prev.map((d) => d.id === id ? { ...d, ...updates, updatedAt: new Date().toISOString() } : d, ), ); // Enqueue a granular patch-deck-fields op — only the changed fields are // sent to the server, so concurrent edits to slides are never clobbered. // Exclude internal/derived fields that live only in client state. const { slides: _slides, createdAt: _ca, ...persistableUpdates } = { slides: undefined, createdAt: undefined, ...updates, }; void _slides; _ca; if (Object.keys(persistableUpdates).length > 0) { const op: PatchDeckOp = { op: "patch-deck-fields", fields: persistableUpdates as PatchDeckFields, }; enqueueDeckOp(id, op); if (before) { // Coalesce rapid deck-field edits (e.g. title typing, tweak sliders) // per field-set so a burst becomes one undo step. recordUndo(before, op, { label: "Update deck", coalesceKey: `${id}:deck-fields:${Object.keys(persistableUpdates) .sort() .join(",")}`, }); } } }, [markDeckDirty, recordUndo], ); const getDeck = useCallback( (id: string) => decks.find((d) => d.id === id), [decks], ); const addSlide = useCallback( (deckId: string, layout: SlideLayout = "content", afterIndex?: number) => { markDeckDirty(deckId); const newSlide: Slide = { id: nanoid(8), content: defaultSlideContent[layout], notes: "", layout, background: "bg-[#000000]", }; const before = decksRef.current.find((d) => d.id === deckId); let afterSlideId: string | undefined; setDecksLocal((prev) => prev.map((d) => { if (d.id !== deckId) return d; const slides = [...d.slides]; const insertAt = afterIndex !== undefined ? afterIndex + 1 : slides.length; // Capture the slide ID we're inserting after for the granular op afterSlideId = insertAt > 0 ? slides[insertAt - 1]?.id : undefined; slides.splice(insertAt, 0, newSlide); return { ...d, slides, updatedAt: new Date().toISOString() }; }), ); // Granular op — the server splices in only this slide, preserving any // concurrent changes to other slides. const op: PatchDeckOp = { op: "add-slide", slideId: newSlide.id, afterSlideId, fields: { content: newSlide.content, notes: newSlide.notes, layout: newSlide.layout, background: newSlide.background, }, }; enqueueDeckOp(deckId, op); if (before) recordUndo(before, op, { label: "Add slide" }); return newSlide.id; }, [markDeckDirty, recordUndo, setDecksLocal], ); const updateSlide = useCallback( (deckId: string, slideId: string, updates: Partial>) => { markDeckDirty(deckId); const label = updates.layout ? "Change layout" : updates.background ? "Change background" : updates.content ? "Update content" : "Edit slide"; const before = decksRef.current.find((d) => d.id === deckId); setDecksLocal((prev: Deck[]) => prev.map((d) => { if (d.id !== deckId) return d; return { ...d, slides: d.slides.map((s) => s.id === slideId ? { ...s, ...updates } : s, ), updatedAt: new Date().toISOString(), }; }), ); // Granular op — only this slide's changed fields reach the server. const op: PatchDeckOp = { op: "patch-slide", slideId, fields: updates }; enqueueDeckOp(deckId, op); if (before) { // Coalesce a burst of edits to the SAME slide's SAME field-set into one // undo step (e.g. typing characters into inline text). Distinct // field-sets (content vs background vs layout) get distinct undo steps. recordUndo(before, op, { label, coalesceKey: `${deckId}:${slideId}:${Object.keys(updates) .sort() .join(",")}`, }); } }, [markDeckDirty, recordUndo, setDecksLocal], ); const deleteSlide = useCallback( (deckId: string, slideId: string) => { markDeckDirty(deckId); const before = decksRef.current.find((d) => d.id === deckId); setDecksLocal((prev) => prev.map((d) => { if (d.id !== deckId) return d; const slides = d.slides.filter((s) => s.id !== slideId); if (slides.length === 0) { slides.push({ id: nanoid(8), content: defaultSlideContent.blank, notes: "", layout: "blank", }); } return { ...d, slides, updatedAt: new Date().toISOString() }; }), ); // Granular op — server deletes only this slide from the blob. const op: PatchDeckOp = { op: "delete-slide", slideId }; enqueueDeckOp(deckId, op); // Inverse re-adds the full prior slide at its old position, so undo // restores content/notes/layout/background exactly. (This is the case // behind the "Undo delete" toast in DeckEditor.) if (before) recordUndo(before, op, { label: "Delete slide" }); }, [markDeckDirty, recordUndo, setDecksLocal], ); const duplicateSlide = useCallback( (deckId: string, slideId: string) => { const before = decksRef.current.find((d) => d.id === deckId); const original = before?.slides.find((slide) => slide.id === slideId); if (!before || !original) return; markDeckDirty(deckId); const copiedSlide: Slide = { ...original, id: nanoid(8) }; setDecksLocal((prev) => prev.map((d) => { if (d.id !== deckId) return d; const idx = d.slides.findIndex((s) => s.id === slideId); if (idx === -1) return d; const slides = [...d.slides]; slides.splice(idx + 1, 0, copiedSlide); return { ...d, slides, updatedAt: new Date().toISOString() }; }), ); // Granular add-slide op — inserts the copy after the original. Build it // from the current deck before scheduling the React state update; the // functional updater runs later and cannot be used to produce the op. const { id: newSlideId, ...rest } = copiedSlide; const op: PatchDeckOp = { op: "add-slide", slideId: newSlideId, afterSlideId: slideId, fields: { content: rest.content, notes: rest.notes, layout: rest.layout, background: rest.background, }, }; enqueueDeckOp(deckId, op); recordUndo(before, op, { label: "Duplicate slide" }); }, [markDeckDirty, recordUndo, setDecksLocal], ); const reorderSlides = useCallback( (deckId: string, oldIndex: number, newIndex: number) => { markDeckDirty(deckId); const before = decksRef.current.find((d) => d.id === deckId); let orderedIds: string[] | undefined; setDecksLocal((prev) => prev.map((d) => { if (d.id !== deckId) return d; const slides = [...d.slides]; const [moved] = slides.splice(oldIndex, 1); slides.splice(newIndex, 0, moved); orderedIds = slides.map((s) => s.id); return { ...d, slides, updatedAt: new Date().toISOString() }; }), ); if (orderedIds) { // Granular op — server reorders by slide ID rather than by index, // so concurrent adds from other writers don't get dropped. const op: PatchDeckOp = { op: "reorder-slides", orderedIds }; enqueueDeckOp(deckId, op); if (before) recordUndo(before, op, { label: "Reorder slides" }); } }, [markDeckDirty, recordUndo, setDecksLocal], ); const setDeckSlides = useCallback( (deckId: string, slides: Slide[]) => { markDeckDirty(deckId); const before = decksRef.current.find((deck) => deck.id === deckId); const after = before ? { ...before, slides, updatedAt: new Date().toISOString() } : null; // setDeckSlides replaces ALL slides wholesale (used by AI generation and // imports), so its undo entry is a deck-level full replacement instead of // a fine-grained slide patch. setDecksLocal((prev) => prev.map((d) => { if (d.id !== deckId) return d; const next = after ?? { ...d, slides, updatedAt: new Date().toISOString(), }; enqueueDeckOp(deckId, { op: "full-replace", deck: next }); return next; }), ); if (before && after) { undoControllerRef.current?.push({ undo: [{ op: "replace-deck", deckId, deck: before }], redo: [{ op: "replace-deck", deckId, deck: after }], label: "Replace slides", }); } }, [markDeckDirty, setDecksLocal], ); return ( {children} ); } export function useDecks() { const ctx = useContext(DeckContext); if (!ctx) throw new Error("useDecks must be used within DeckProvider"); return ctx; } /** * Subscribe to deck save-state. Returns `{ saving: boolean }` — true while any * deck has a pending debounce timer or an in-flight PUT. * * Used by SaveStatusIndicator in the toolbar so users always see whether * their work has been committed (Rochkind reported losing a full deck because * there was no save signal). */ export function useSaveState(): { saving: boolean } { return useSyncExternalStore(subscribeSaveState, getSaveSnapshot, () => ({ saving: false, })); }