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<FirstRunGateState>(() => ({ 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 <FirstRunRecovery reason="settings" retrying={hydrationStatus === "retrying"} />; } if (decision !== "app") { return stalled ? <FirstRunRecovery reason="connection" /> : null; } return children;} function FirstRunRecovery({ reason, retrying = false,}: { readonly reason: "settings" | "connection"; readonly retrying?: boolean;}) { const settingsReadFailed = reason === "settings"; return ( <main className="flex h-dvh min-h-0 items-center justify-center bg-background px-6 text-foreground"> <div className="flex max-w-sm flex-col items-center text-center"> <h1 className="text-lg font-semibold"> {settingsReadFailed ? "Could not read settings" : "Still connecting"} </h1> <p className="mt-2 text-sm text-muted-foreground"> {settingsReadFailed ? "Your saved settings could not be loaded." : "T3 Code could not confirm this workspace."} </p> <Button className="mt-5" size="sm" variant="outline" disabled={retrying} onClick={() => { if (settingsReadFailed) { void ensureClientSettingsHydrated().catch(() => undefined); } else { window.location.reload(); } }} > <RefreshIcon refreshing={retrying} /> {settingsReadFailed ? "Retry" : "Reload"} </Button> </div> </main> );}
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<FirstRunGateState>(() => ({
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 <FirstRunRecovery reason="settings" retrying={hydrationStatus === "retrying"} />;
}
if (decision !== "app") {
return stalled ? <FirstRunRecovery reason="connection" /> : null;
}
return children;
}
function FirstRunRecovery({
reason,
retrying = false,
}: {
readonly reason: "settings" | "connection";
readonly retrying?: boolean;
}) {
const settingsReadFailed = reason === "settings";
return (
<main className="flex h-dvh min-h-0 items-center justify-center bg-background px-6 text-foreground">
<div className="flex max-w-sm flex-col items-center text-center">
<h1 className="text-lg font-semibold">
{settingsReadFailed ? "Could not read settings" : "Still connecting"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{settingsReadFailed
? "Your saved settings could not be loaded."
: "T3 Code could not confirm this workspace."}
</p>
<Button
className="mt-5"
size="sm"
variant="outline"
disabled={retrying}
onClick={() => {
if (settingsReadFailed) {
void ensureClientSettingsHydrated().catch(() => undefined);
} else {
window.location.reload();
}
}}
>
<RefreshIcon refreshing={retrying} />
{settingsReadFailed ? "Retry" : "Reload"}
</Button>
</div>
</main>
);
}