File size: 6,166 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
/**
 * 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;
}