1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { useAtomValue } from "@effect/atom-react";
3import { useLocation, useNavigate } from "@tanstack/react-router";
4import { Atom } from "effect/reactivity";
5import { useEffect, useState } from "react";
6
7import {
8 ensureClientSettingsHydrated,
9 useClientSettings,
10 useClientSettingsHydrationStatus,
11} from "../../hooks/useSettings";
12import { isLocalEnvironmentDisabled } from "../../localEnvironment";
13import { useCompleteOnboarding } from "../../onboarding/firstRun";
14import {
15 isFirstRunWorkspaceProvenanceAuthoritative,
16 isFreshFirstRunWorkspace,
17 resolveFirstRunDecision,
18 resolveHostedFirstRunDecision,
19 transitionFirstRunGateState,
20 type FirstRunGateState,
21} from "../../onboarding/firstRun.logic";
22import {
23 useAllEnvironmentShellsBootstrapped,
24 useProjects,
25 useThreadShells,
26} from "../../state/entities";
27import { useEnvironments } from "../../state/environments";
28import { environmentProjects } from "../../state/projects";
29import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server";
30import { environmentShell } from "../../state/shell";
31import { environmentThreadShells } from "../../state/threads";
32import { Button } from "../ui/button";
33
34/**
35 * Holds back authenticated and hosted app trees until the first-run decision
36 * is known, so a fresh install never flashes the main screen before the wizard.
37 * Nothing renders while pending — no shell, no EventRouter (whose welcome
38 * payload would otherwise navigate into a thread), no dialogs.
39 *
40 * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as
41 * settings hydrate (the common case, no server round-trip). A `null` flag also
42 * covers installs that predate the field, so it alone is not enough — the gate
43 * waits for environment shells to bootstrap and inspects the workspace.
44 * Hosted mode instead checks its saved environment catalog. A timeout shows
45 * recovery for an unreachable primary server without mounting the app tree.
46 */
47
48const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000;
49
50const primaryShellLiveAtom = Atom.make((get) => {
51 const serverConfig = get(primaryServerConfigAtom);
52 return (
53 serverConfig !== null &&
54 get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live"
55 );
56}).pipe(Atom.withLabel("web-onboarding-primary-shell-live"));
57
58const workspaceEvidenceLiveAtom = Atom.make((get) => {
59 const environmentIds = new Set([
60 ...get(environmentProjects.projectsAtom).map((project) => project.environmentId),
61 ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId),
62 ]);
63
64 for (const environmentId of environmentIds) {
65 if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") {
66 return false;
67 }
68 }
69
70 return true;
71}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live"));
72
73export function FirstRunGate({
74 enabled,
75 hostedStatic,
76 children,
77}: {
78 readonly enabled: boolean;
79 readonly hostedStatic: boolean;
80 readonly children: React.ReactNode;
81}) {
82 const navigate = useNavigate();
83 const pathname = useLocation({ select: (location) => location.pathname });
84 const hydrationStatus = useClientSettingsHydrationStatus();
85 const hydrated = hydrationStatus === "ready";
86 const completeOnboarding = useCompleteOnboarding();
87 const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt);
88 const bootstrapped = useAllEnvironmentShellsBootstrapped();
89 const { environments, isReady: environmentCatalogReady } = useEnvironments();
90 const projects = useProjects();
91 const threads = useThreadShells();
92 const serverConfig = useAtomValue(primaryServerConfigAtom);
93 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
94 const primaryShellLive = useAtomValue(primaryShellLiveAtom);
95 const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom);
96 // Within a session settings stay hydrated, so remounts (e.g. returning from
97 // the wizard) resolve synchronously instead of blanking a frame.
98 const [gateState, setGateState] = useState<FirstRunGateState>(() => ({
99 decision:
100 (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null)
101 ? "app"
102 : "pending",
103 stalled: false,
104 }));
105 const { decision, stalled } = gateState;
106 const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying";
107 // A workspace still counts as fresh when its only content is the server's
108 // own cwd auto-bootstrap: web mode creates a project + thread from cwd at
109 // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no
110 // projects at all" would mean `npx t3` users never see the wizard. Any
111 // other project, more than one thread, or state in a non-primary
112 // environment is real user state — the aggregate hooks span every
113 // environment, and a saved remote's project must never read as "the
114 // bootstrap project" just because its root string matches the primary cwd.
115 const serverCwd = serverConfig?.cwd ?? null;
116 const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null;
117 const workspaceFresh = isFreshFirstRunWorkspace({
118 primaryEnvironmentId,
119 serverCwd,
120 bootstrapProjectId: serverWelcome?.bootstrapProjectId,
121 bootstrapThreadId: serverWelcome?.bootstrapThreadId,
122 bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated,
123 bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated,
124 projects,
125 threads,
126 });
127
128 const { decision: nextDecision, persistCompletion } = hostedStatic
129 ? resolveHostedFirstRunDecision({
130 localEnvironmentDisabled: isLocalEnvironmentDisabled(),
131 hydrated,
132 completed: onboardingCompletedAt !== null,
133 catalogReady: environmentCatalogReady,
134 environmentCount: environments.length,
135 })
136 : resolveFirstRunDecision({
137 enabled,
138 hydrated,
139 completed: onboardingCompletedAt !== null,
140 bootstrapped,
141 authoritative: primaryShellLive,
142 workspaceAuthoritative: workspaceEvidenceLive,
143 workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({
144 welcomeReceived: serverWelcome !== null,
145 bootstrapStatus: serverWelcome?.bootstrapStatus ?? null,
146 }),
147 catalogReady: environmentCatalogReady,
148 serverConfigAvailable: serverConfig !== null,
149 workspaceFresh,
150 projectCount: projects.length,
151 threadCount: threads.length,
152 });
153
154 useEffect(() => {
155 if (decision === "wizard" || !hydrated) return;
156
157 if (persistCompletion && onboardingCompletedAt === null) {
158 void completeOnboarding().catch(() => undefined);
159 }
160
161 setGateState((state) =>
162 transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }),
163 );
164 }, [
165 completeOnboarding,
166 decision,
167 hydrated,
168 nextDecision,
169 onboardingCompletedAt,
170 persistCompletion,
171 ]);
172
173 // A stalled server read gets a recovery screen, but never mounts the app.
174 // The timer starts after settings hydrate so slow local hydration does not
175 // show a false connection failure.
176 useEffect(() => {
177 if (!enabled || decision !== "pending" || !hydrated) return;
178 const timer = window.setTimeout(
179 () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })),
180 FIRST_RUN_DECISION_TIMEOUT_MS,
181 );
182 return () => window.clearTimeout(timer);
183 }, [decision, enabled, hydrated]);
184
185 useEffect(() => {
186 if (decision === "wizard" && pathname !== "/welcome") {
187 void navigate({ to: "/welcome", replace: true });
188 }
189 }, [decision, navigate, pathname]);
190
191 if (settingsReadFailed) {
192 return <FirstRunRecovery reason="settings" retrying={hydrationStatus === "retrying"} />;
193 }
194 if (decision !== "app") {
195 return stalled ? <FirstRunRecovery reason="connection" /> : null;
196 }
197 return children;
198}
199
200function FirstRunRecovery({
201 reason,
202 retrying = false,
203}: {
204 readonly reason: "settings" | "connection";
205 readonly retrying?: boolean;
206}) {
207 const settingsReadFailed = reason === "settings";
208 return (
209 <main className="flex h-dvh min-h-0 items-center justify-center bg-background px-6 text-foreground">
210 <div className="flex max-w-sm flex-col items-center text-center">
211 <h1 className="text-lg font-semibold">
212 {settingsReadFailed ? "Could not read settings" : "Still connecting"}
213 </h1>
214 <p className="mt-2 text-sm text-muted-foreground">
215 {settingsReadFailed
216 ? "Your saved settings could not be loaded."
217 : "The Knife could not confirm this workspace."}
218 </p>
219 <Button
220 className="mt-5"
221 size="sm"
222 variant="outline"
223 disabled={retrying}
224 onClick={() => {
225 if (settingsReadFailed) {
226 void ensureClientSettingsHydrated().catch(() => undefined);
227 } else {
228 window.location.reload();
229 }
230 }}
231 >
232 <RefreshIcon refreshing={retrying} />
233 {settingsReadFailed ? "Retry" : "Reload"}
234 </Button>
235 </div>
236 </main>
237 );
238}