/** * Wizard progress store — module-level singleton that survives tab * unmounts. * * Why * --- * The Interactive wizard's "Generating scenes…" modal used to live as * local React state inside ``WizardAutoPreview``. When the user clicked * away to Chat / Imagine / Voice mid-generation, ``InteractiveHost`` * unmounted, the wizard component went with it, the SSE stream got * cancelled, and the modal vanished — even though the backend was * still rendering scenes. Coming back showed nothing because the * AbortController fired on unmount and the new mount started fresh. * * What this gives us * ------------------ * One store at module scope. State persists across mount/unmount of * any consumer. The SSE stream is owned here, not in any one * component, so navigating away doesn't tear it down. A single * ```` rendered at App level reads the same * store and stays visible across tabs. * * Usage * ----- * ```tsx * // From the wizard: * await wizardProgressStore.startGeneration({ api, payload, onDone }); * * // From any component (incl. App): * const state = useWizardProgress(); * if (state.active) ; * ``` * * The store is intentionally tiny — no Redux, no Zustand, no ts-pattern. * ``useSyncExternalStore`` is the React 18 primitive built for this. */ import { useSyncExternalStore } from "react"; import type { InteractiveApi } from "./api"; import type { AutoGenerateResult } from "./types"; type GenerateAllResult = AutoGenerateResult; export type WizardProgressEventType = | "rendering_started" | "rendering_scene" | "scene_rendered" | "scene_skipped" | "scene_render_failed" | "library_build_started" | "library_asset_rendered" | "library_asset_failed" | "library_build_done" | "scene_linked" // Expert-mode chain-of-thought events. Forwarded as-is from the // backend planner / workflow runner so the expert log panel can // display CrewAI-style step traces. | "planner_thought" | "workflow_started" | "workflow_completed" | "step_started" | "step_completed" | "step_failed" | "llm_step_started" | "llm_step_completed" | "llm_step_failed"; export interface ExpertLogEntry { /** Sequential id, monotonic per ``startGeneration`` call. */ id: number; /** Wall-clock millis when the entry was added. */ ts: number; /** Raw SSE event type. */ type: string; /** Short human-readable label derived from the event for display. */ label: string; /** One-line summary built from the payload (e.g. preview text). */ summary: string; /** Full payload for the "show details" expander. */ payload: Record; /** "thought" | "step" | "llm" | "phase" — used for visual grouping. */ kind: "thought" | "step" | "llm" | "phase" | "render"; /** Set when the event represents a failure path. */ failed?: boolean; } export interface WizardProgressState { /** True from the moment ``startGeneration`` is called until the * flow either resolves with a result or fails. */ active: boolean; /** Active step index (0..N-1) into the wizard's GENERATE_STEPS. */ genStep: number; /** Total number of scene targets reported by ``rendering_started``. */ renderTotal: number; /** Number of scenes successfully rendered or skipped. */ renderDone: number; /** Subset of ``renderDone`` that were skipped vs successful. */ renderSkipped: number; /** Title of the scene currently being rendered (UI hint). */ currentSceneTitle: string; /** Set when generation fails before producing a result. */ error: string | null; /** Resolved when generation completes with a usable scene graph. */ result: GenerateAllResult | null; /** The experience id created at the start of the run. The wizard * reads this to navigate to the editor when the run finishes * while the wizard is still mounted; when the user has navigated * away the global overlay shows an "Open project" button that * reuses this id. */ experienceId: string | null; /** Persona library build phase events surfaced separately so the * modal can show ("Tier 1: 5/9 ready") without conflating with * the scene-render counter. */ libraryTotal: number; libraryDone: number; libraryFailed: number; /** Expert-mode chain-of-thought log. Always populated regardless of * whether the panel is visible — the toggle just decides display. * Capped at MAX_EXPERT_LOG entries to keep memory bounded on long * runs (a 60-scene render can emit 120+ events). */ expertLog: ExpertLogEntry[]; } const MAX_EXPERT_LOG = 200; const initialState: WizardProgressState = { active: false, genStep: 0, renderTotal: 0, renderDone: 0, renderSkipped: 0, currentSceneTitle: "", error: null, result: null, experienceId: null, libraryTotal: 0, libraryDone: 0, libraryFailed: 0, expertLog: [], }; let expertLogSeq = 0; function _classifyEvent(type: string): ExpertLogEntry["kind"] { if (type === "planner_thought") return "thought"; if (type.startsWith("llm_step")) return "llm"; if (type === "workflow_started" || type === "workflow_completed" || type === "step_started" || type === "step_completed" || type === "step_failed") return "step"; if (type.startsWith("rendering_") || type.startsWith("scene_") || type.startsWith("library_")) return "render"; return "phase"; } function _summarizeEvent( type: string, payload: Record, ): { label: string; summary: string } { const p = payload || {}; const peek = (k: string): string => { const v = p[k]; return typeof v === "string" ? v : v == null ? "" : String(v); }; switch (type) { case "planner_thought": return { label: peek("label") || "Planner thinking", summary: [peek("path"), peek("error")].filter(Boolean).join(" · ") }; case "workflow_started": return { label: `Workflow → ${peek("workflow") || "?"}`, summary: `${(p.step_ids as unknown[] || []).length} step(s)` }; case "workflow_completed": return { label: `Workflow ${peek("ok") === "true" || p.ok ? "complete" : "ended"}`, summary: `${peek("duration_ms") || "?"} ms` }; case "step_started": return { label: `Step → ${peek("step_id") || peek("prompt_id") || "?"}`, summary: `prompt=${peek("prompt_id") || "?"}` }; case "step_completed": return { label: `Step ✓ ${peek("step_id") || "?"}`, summary: peek("preview") || `${peek("duration_ms") || "?"} ms` }; case "step_failed": return { label: `Step ✗ ${peek("step_id") || "?"}`, summary: peek("reason") || "failed" }; case "llm_step_started": return { label: `LLM call → ${peek("step_id") || "?"}`, summary: peek("user_preview") || peek("system_preview") || "" }; case "llm_step_completed": return { label: `LLM ✓ ${peek("step_id") || "?"}`, summary: peek("preview") || `${peek("output_chars") || "?"} chars` }; case "llm_step_failed": return { label: `LLM ✗ ${peek("step_id") || "?"}`, summary: peek("reason") || "failed" }; case "rendering_scene": return { label: `Rendering scene ${peek("index") || ""}/${peek("total") || ""}`, summary: peek("title") }; case "scene_rendered": return { label: "Scene rendered", summary: peek("title") || peek("scene_id") }; case "scene_skipped": return { label: "Scene skipped", summary: peek("title") || peek("reason") || "" }; case "scene_render_failed": return { label: "Scene render failed", summary: peek("reason") || peek("title") }; default: return { label: type, summary: "" }; } } function _appendExpertLog( type: string, payload: Record, ): void { const { label, summary } = _summarizeEvent(type, payload); const entry: ExpertLogEntry = { id: ++expertLogSeq, ts: Date.now(), type, label, summary, payload, kind: _classifyEvent(type), failed: type.endsWith("_failed"), }; const next = state.expertLog.length >= MAX_EXPERT_LOG ? [...state.expertLog.slice(-MAX_EXPERT_LOG + 1), entry] : [...state.expertLog, entry]; setState({ expertLog: next }); } let state: WizardProgressState = { ...initialState }; const subscribers = new Set<() => void>(); function emit(): void { subscribers.forEach((fn) => { try { fn(); } catch { /* a subscriber bug must not crash the store */ } }); } function setState(patch: Partial): void { state = { ...state, ...patch }; emit(); } function subscribe(fn: () => void): () => void { subscribers.add(fn); return () => { subscribers.delete(fn); }; } function getSnapshot(): WizardProgressState { return state; } /** * Subscribe to the store from a React component. * * The component re-renders whenever any field on the state changes. * Cheap because we never mutate the state object — every update goes * through ``setState`` which produces a new reference. */ export function useWizardProgress(): WizardProgressState { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } // ── Phase-name → step-index map ────────────────────────────────── // // Mirrors the GENERATE_STEPS labels in WizardAutoPreview so the // modal's progress dots advance as the SSE feed reports phase events. // Kept here so the store doesn't need to import WizardAutoPreview. const STEP_INDEX_BY_PHASE: Record = { started: 0, generating_graph: 1, graph_generated: 2, persisting_nodes: 2, persisting_edges: 2, persisting_actions: 2, seeding_rule: 2, running_qa: 2, qa_done: 2, rendering_started: 3, rendering_scene: 3, scene_rendered: 3, scene_skipped: 3, scene_render_failed: 3, scene_deferred: 3, rendering_done: 3, library_build_started: 3, library_rendering_asset: 3, library_asset_rendered: 3, library_asset_failed: 3, library_build_done: 3, scene_linked: 4, done: 4, result: 4, }; function _stepIndexForPhase(phase: string): number | null { const idx = STEP_INDEX_BY_PHASE[phase]; return typeof idx === "number" ? idx : null; } // ── Public entry point ────────────────────────────────────────── interface StartGenerationArgs { api: InteractiveApi; payload: Parameters[0]; onCreated?: (experienceId: string) => void; /** Total number of phases the wizard renders as steps. The store * caps ``genStep`` so the final state visible after completion * still reads "Opening the editor" (last step). */ totalSteps: number; } /** * Run the full create-experience + generate-all SSE flow against the * backend. Updates the store as events arrive. Resolves when the run * finishes (success or fail). * * Safe to call again after a previous run finishes — the store is * reset to the initial state at the top of each call. NOT safe to * call concurrently — the second call will clobber the first run's * counters. The wizard's submit button is disabled while * ``state.active`` is true; that's the gate. */ export async function startGeneration( args: StartGenerationArgs, ): Promise { expertLogSeq = 0; setState({ ...initialState, active: true, }); let createdId: string | null = null; try { const created = await args.api.createExperience(args.payload); createdId = created.id; setState({ experienceId: created.id, genStep: 1 }); args.onCreated?.(created.id); const result = await args.api.generateAllStream(created.id, { onEvent: (ev) => { // Always append to the expert log first — the panel is the // most useful when the run is failing, and tossing entries // before phase logic short-circuits would lose evidence. _appendExpertLog( ev.type, (ev.payload as Record) || {}, ); const idx = _stepIndexForPhase(ev.type); if (idx !== null) { setState({ genStep: Math.max(state.genStep, idx) }); } if (ev.type === "rendering_started") { const total = Number((ev.payload as any)?.total || 0); setState({ renderTotal: total, renderDone: 0, renderSkipped: 0 }); } if (ev.type === "rendering_scene") { const title = String((ev.payload as any)?.title || ""); if (title) setState({ currentSceneTitle: title }); } if (ev.type === "scene_rendered") { setState({ renderDone: state.renderDone + 1 }); } if (ev.type === "scene_skipped" || ev.type === "scene_render_failed") { setState({ renderDone: state.renderDone + 1, renderSkipped: state.renderSkipped + 1, }); } // Persona library build events — surfaced under a separate // counter so a 5-asset library build doesn't pollute the // scene-render percentage that drives the main progress bar. if (ev.type === "library_build_started") { const total = Number((ev.payload as any)?.total || 0); setState({ libraryTotal: total, libraryDone: 0, libraryFailed: 0 }); } if (ev.type === "library_asset_rendered") { setState({ libraryDone: state.libraryDone + 1 }); } if (ev.type === "library_asset_failed") { setState({ libraryDone: state.libraryDone + 1, libraryFailed: state.libraryFailed + 1, }); } }, }); setState({ genStep: Math.max(state.genStep, args.totalSteps - 1), result, active: false, }); return result; } catch (err) { const message = err instanceof Error ? err.message : "Generation failed."; setState({ error: message, active: false, experienceId: createdId, }); return null; } } /** * Drop the result + error AFTER the wizard has consumed them. The * global overlay listens for ``state.active===false && state.result`` * to show the "Done" state with an Open Project button; once the * wizard navigates to the editor, it calls this so a future run * doesn't show stale state. */ export function clearGeneration(): void { state = { ...initialState }; emit(); } /** * Reset just the active flag without losing the result — used when * the wizard navigates the user to the editor after the SSE finished. */ export function dismissOverlay(): void { setState({ active: false }); }