HomePilot / frontend /src /ui /interactive /WizardProgressOverlay.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
6.17 kB
/**
* Global wizard-progress overlay.
*
* Mounted once at App level so the "Generating scenes…" modal
* persists across tab switches. WizardAutoPreview used to render
* this inline, which meant clicking Chat / Imagine / Voice mid-
* generation unmounted the wizard and the modal vanished even
* though the SSE stream was still running on the backend.
*
* State lives in ``wizardProgressStore`` (module-level singleton);
* this component is a pure consumer.
*
* Renders nothing when ``state.active === false``. When active, it
* portals a fixed-position overlay over the entire viewport so it
* stays on top of whatever route the user has navigated to.
*/
import React, { useMemo } from "react";
import { createPortal } from "react-dom";
import { ExpertLogPanel, useExpertMode } from "./ExpertLogPanel";
import { GeneratingPanel } from "./GeneratingPanel";
import {
useWizardProgress,
dismissOverlay,
type WizardProgressState,
} from "./wizardProgressStore";
const GENERATE_STEPS = [
{ label: "Saving your project" },
{ label: "Drafting the scene graph" },
{ label: "Writing dialogue + choices" },
{ label: "Rendering scenes" },
{ label: "Opening the editor" },
] as const;
function _panelTitle(s: WizardProgressState, renderEnabled: boolean): string {
if (s.error) return "Generation failed";
if (s.renderTotal > 0 && s.renderDone < s.renderTotal) {
return `Rendering scenes · ${s.renderDone + 1} of ${s.renderTotal}`;
}
if (s.libraryTotal > 0 && s.libraryDone < s.libraryTotal) {
return `Building persona library · ${s.libraryDone + 1} of ${s.libraryTotal}`;
}
if (s.genStep >= GENERATE_STEPS.length - 1) return "Opening the editor";
return GENERATE_STEPS[Math.max(0, Math.min(s.genStep, GENERATE_STEPS.length - 1))].label;
}
function _panelDescription(s: WizardProgressState): string {
if (s.error) return s.error;
if (s.currentSceneTitle && s.renderTotal > 0 && s.renderDone < s.renderTotal) {
return `Now rendering: ${s.currentSceneTitle}`;
}
if (s.libraryTotal > 0 && s.libraryDone < s.libraryTotal) {
return "Pre-generating the persona's reaction pack — this is a one-time pass per persona.";
}
if (s.genStep === GENERATE_STEPS.length - 1) {
return "Almost ready — opening the editor.";
}
return "AI is working — this usually takes a few seconds.";
}
export function WizardProgressOverlay(): React.ReactElement | null {
const state = useWizardProgress();
const [expertMode] = useExpertMode();
const showProgressBar = useMemo(
() => state.renderTotal > 0,
[state.renderTotal],
);
const progressPct = useMemo(() => {
if (!showProgressBar) return undefined;
return Math.round((state.renderDone / Math.max(1, state.renderTotal)) * 100);
}, [showProgressBar, state.renderDone, state.renderTotal]);
const progressLabel = useMemo(() => {
if (!showProgressBar) return undefined;
return `${state.renderDone} / ${state.renderTotal} scenes${
state.renderSkipped > 0 ? ` · ${state.renderSkipped} skipped` : ""
}`;
}, [showProgressBar, state.renderDone, state.renderTotal, state.renderSkipped]);
// Hide when nothing's running. Errors are shown for ~5 s after the
// run fails so the user has a moment to read them before the
// overlay clears (ushered out by the wizard's inline retry button).
if (!state.active && !state.error) {
return null;
}
// The wizard route renders its own GeneratingPanel inline. We don't
// want two stacked modals when the user IS on the wizard route —
// detect that by looking for the wizard's container in the DOM.
// Cheap, race-free: we render at App level only when no inline
// overlay is mounted by WizardAutoPreview.
//
// Implementation: WizardAutoPreview no longer renders its own panel
// (the inline render is now this component), so always show the
// overlay when the store says we're active.
const node = (
<div
role="dialog"
aria-modal="true"
aria-label="Project generation progress"
style={{
position: "fixed",
inset: 0,
zIndex: 9000,
background: "rgba(0,0,0,0.55)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
}}
>
<div
style={{
maxWidth: expertMode ? 920 : 480,
width: "100%",
display: "flex",
flexDirection: expertMode ? "row" : "column",
gap: expertMode ? "0.75rem" : 0,
alignItems: "stretch",
}}
>
<div style={{ flex: expertMode ? "0 0 420px" : "1 1 auto", minWidth: 0 }}>
<GeneratingPanel
title={_panelTitle(state, true)}
description={_panelDescription(state)}
steps={GENERATE_STEPS as unknown as Array<{ label: string }>}
activeStep={state.genStep}
accentClassName="text-[#c4b5fd]"
spinnerSize="large"
progress={progressPct}
progressLabel={progressLabel}
/>
</div>
{expertMode && (
<ExpertLogPanel
className="flex-1 min-w-0 max-h-[28rem]"
/>
)}
{state.error && (
<div
style={{
marginTop: "0.75rem",
display: "flex",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={() => dismissOverlay()}
style={{
padding: "0.5rem 1rem",
background: "#1f2937",
color: "#f1f1f1",
border: "1px solid #374151",
borderRadius: 6,
cursor: "pointer",
}}
>
Dismiss
</button>
</div>
)}
</div>
</div>
);
// Portal to body so this floats above the entire app shell
// regardless of which route is active when the user navigates.
return typeof document !== "undefined"
? createPortal(node, document.body)
: null;
}