import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { Atom } from "effect/unstable/reactivity"; import { useEffect, useState } from "react"; import { ensureClientSettingsHydrated, useClientSettings, useClientSettingsHydrationStatus, } from "../../hooks/useSettings"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { isFirstRunWorkspaceProvenanceAuthoritative, isFreshFirstRunWorkspace, resolveFirstRunDecision, resolveHostedFirstRunDecision, transitionFirstRunGateState, type FirstRunGateState, } from "../../onboarding/firstRun.logic"; import { useAllEnvironmentShellsBootstrapped, useProjects, useThreadShells, } from "../../state/entities"; import { useEnvironments } from "../../state/environments"; import { environmentProjects } from "../../state/projects"; import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; import { environmentShell } from "../../state/shell"; import { environmentThreadShells } from "../../state/threads"; import { Button } from "../ui/button"; /** * Holds back authenticated and hosted app trees until the first-run decision * is known, so a fresh install never flashes the main screen before the wizard. * Nothing renders while pending — no shell, no EventRouter (whose welcome * payload would otherwise navigate into a thread), no dialogs. * * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as * settings hydrate (the common case, no server round-trip). A `null` flag also * covers installs that predate the field, so it alone is not enough — the gate * waits for environment shells to bootstrap and inspects the workspace. * Hosted mode instead checks its saved environment catalog. A timeout shows * recovery for an unreachable primary server without mounting the app tree. */ const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; const primaryShellLiveAtom = Atom.make((get) => { const serverConfig = get(primaryServerConfigAtom); return ( serverConfig !== null && get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" ); }).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); const workspaceEvidenceLiveAtom = Atom.make((get) => { const environmentIds = new Set([ ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), ]); for (const environmentId of environmentIds) { if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { return false; } } return true; }).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); export function FirstRunGate({ enabled, hostedStatic, children, }: { readonly enabled: boolean; readonly hostedStatic: boolean; readonly children: React.ReactNode; }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const hydrationStatus = useClientSettingsHydrationStatus(); const hydrated = hydrationStatus === "ready"; const completeOnboarding = useCompleteOnboarding(); const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const { environments, isReady: environmentCatalogReady } = useEnvironments(); const projects = useProjects(); const threads = useThreadShells(); const serverConfig = useAtomValue(primaryServerConfigAtom); const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const primaryShellLive = useAtomValue(primaryShellLiveAtom); const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); // Within a session settings stay hydrated, so remounts (e.g. returning from // the wizard) resolve synchronously instead of blanking a frame. const [gateState, setGateState] = useState(() => ({ decision: (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) ? "app" : "pending", stalled: false, })); const { decision, stalled } = gateState; const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; // A workspace still counts as fresh when its only content is the server's // own cwd auto-bootstrap: web mode creates a project + thread from cwd at // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no // projects at all" would mean `npx t3` users never see the wizard. Any // other project, more than one thread, or state in a non-primary // environment is real user state — the aggregate hooks span every // environment, and a saved remote's project must never read as "the // bootstrap project" just because its root string matches the primary cwd. const serverCwd = serverConfig?.cwd ?? null; const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; const workspaceFresh = isFreshFirstRunWorkspace({ primaryEnvironmentId, serverCwd, bootstrapProjectId: serverWelcome?.bootstrapProjectId, bootstrapThreadId: serverWelcome?.bootstrapThreadId, bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, projects, threads, }); const { decision: nextDecision, persistCompletion } = hostedStatic ? resolveHostedFirstRunDecision({ hydrated, completed: onboardingCompletedAt !== null, catalogReady: environmentCatalogReady, environmentCount: environments.length, }) : resolveFirstRunDecision({ enabled, hydrated, completed: onboardingCompletedAt !== null, bootstrapped, authoritative: primaryShellLive, workspaceAuthoritative: workspaceEvidenceLive, workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ welcomeReceived: serverWelcome !== null, bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, }), catalogReady: environmentCatalogReady, serverConfigAvailable: serverConfig !== null, workspaceFresh, projectCount: projects.length, threadCount: threads.length, }); useEffect(() => { if (decision === "wizard" || !hydrated) return; if (persistCompletion && onboardingCompletedAt === null) { void completeOnboarding().catch(() => undefined); } setGateState((state) => transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), ); }, [ completeOnboarding, decision, hydrated, nextDecision, onboardingCompletedAt, persistCompletion, ]); // A stalled server read gets a recovery screen, but never mounts the app. // The timer starts after settings hydrate so slow local hydration does not // show a false connection failure. useEffect(() => { if (!enabled || decision !== "pending" || !hydrated) return; const timer = window.setTimeout( () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), FIRST_RUN_DECISION_TIMEOUT_MS, ); return () => window.clearTimeout(timer); }, [decision, enabled, hydrated]); useEffect(() => { if (decision === "wizard" && pathname !== "/welcome") { void navigate({ to: "/welcome", replace: true }); } }, [decision, navigate, pathname]); if (settingsReadFailed) { return ; } if (decision !== "app") { return stalled ? : null; } return children; } function FirstRunRecovery({ reason, retrying = false, }: { readonly reason: "settings" | "connection"; readonly retrying?: boolean; }) { const settingsReadFailed = reason === "settings"; return (

{settingsReadFailed ? "Could not read settings" : "Still connecting"}

{settingsReadFailed ? "Your saved settings could not be loaded." : "T3 Code could not confirm this workspace."}

); }