;
+ readonly isImporting: boolean;
+}) {
+ const [reviewing, setReviewing] = useState(false);
+ const {
+ canWrite,
+ candidates,
+ isNew,
+ recent,
+ selected,
+ selectedKeys,
+ setSelectedKeys,
+ visibleImportError,
+ } = projectImport;
+ if (scan === undefined) return null;
+ if (scan.isPending && candidates.length === 0) {
+ return (
+
+
+ Looking for projects in your {IMPORT_SOURCES_LABEL} history…
+
+
+ );
+ }
+ const retry = (
+
+ Retry
+
+ );
+ if (scan.error !== null && candidates.length === 0) {
+ return (
+
+
+ Could not check projects. {scan.error}
+ {retry}
+
+
+ );
+ }
+ if (candidates.length === 0) {
+ return scan.data?.truncated ? (
+
+
+ {SCAN_LIMIT_MESSAGE}
+
+
+ ) : null;
+ }
+
+ const allKeys = candidates.map((candidate) => candidate.key);
+ // Checking the box picks recent new projects, else any new ones, else everything listed.
+ const fresh = candidates.filter(isNew);
+ const defaultKeys = (recent.length > 0 ? recent : fresh.length > 0 ? fresh : candidates).map(
+ (candidate) => candidate.key,
+ );
+ const partial =
+ selected.length > 0 &&
+ selected.length < candidates.length &&
+ !(selected.length === defaultKeys.length && defaultKeys.every((key) => selectedKeys.has(key)));
+ return (
+
+
+
+
+ 0}
+ indeterminate={canWrite && partial}
+ disabled={isImporting || !canWrite}
+ onCheckedChange={(checked) =>
+ setSelectedKeys(new Set(checked ? defaultKeys : []), { optedOut: !checked })
+ }
+ />
+
+
+
+ {canWrite && selected.length > 0
+ ? `Import ${selected.length} ${selected.length === 1 ? "project" : "projects"} and their threads`
+ : "Import projects and their threads"}
+
+
+ From your {IMPORT_SOURCES_LABEL} history
+
+
+
+ {canWrite ? (
+ setReviewing((open) => !open)}
+ >
+ {reviewing ? "Hide" : `Review all ${candidates.length}`}
+
+
+ ) : null}
+
+ {reviewing && canWrite ? (
+ // The negative margin lines the list's checkboxes up with the one above it.
+
+
+
+ {selected.length} of {candidates.length} selected
+
+
+ setSelectedKeys(new Set(allKeys))}
+ >
+ Select all
+
+ setSelectedKeys(new Set())}
+ >
+ Select none
+
+
+
+
+
+
+
+
+
+ ) : null}
+ {scan.data?.truncated ? (
+
+ {SCAN_LIMIT_MESSAGE}
+
+ ) : null}
+ {scan.error !== null ? (
+
+ Could not check projects again. {scan.error}
+ {retry}
+
+ ) : null}
+ {visibleImportError ? (
+ {visibleImportError}
+ ) : null}
+
+ );
+}
+
+function PhoneSection({ environmentId }: { readonly environmentId: EnvironmentId }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+
+
+
+
+ Use T3 Code on your phone
+
+ }>
+ {open ? "Hide" : "Set up"}
+
+
+
+
+
+
+
+ Scan with your phone to get the app.
+
+
+
+
+
+ {/* One code for both stores: the link opens the store that matches the phone. */}
+
+
+
+
+
+
+
+ );
+}
+
+function PhoneStep({ step, children }: { readonly step: number; readonly children: ReactNode }) {
+ return (
+
+ {step}
+ {children}
+
+ );
+}
+
+function PhoneConnectLink({ environmentId }: { readonly environmentId: EnvironmentId }) {
+ const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
+ const { openAuthPrompt } = useT3ConnectAuthPrompt();
+ const linkState = useCloudLinkState(environmentId);
+ const refreshLinkState = useEffectEvent(linkState.refresh);
+ // The server rechecks the tunnel every 30 s; read its latest result while this panel is open.
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 30 s interval that refreshes the cloud link reachability while mounted
+ useEffect(() => {
+ const interval = window.setInterval(() => refreshLinkState(), 30_000);
+ return () => window.clearInterval(interval);
+ }, []);
+ const link = linkState.data;
+ // The phone reaches this computer through its tunnel; older servers leave that flag out.
+ const shared =
+ link !== null &&
+ link.linked &&
+ link.linkRevoked !== true &&
+ link.cloudUserId === userId &&
+ link.managedTunnelActive !== false;
+ // The server checks the tunnel on its own; until it has, the result is unknown.
+ const reachability = shared ? link.reachability?.status : undefined;
+ const settingsLink = (label: string) => (
+ }>
+ {label}
+
+ );
+ if (isSignedIn && link === null && linkState.isPending) {
+ return Checking T3 Connect…
;
+ }
+ if (isSignedIn && linkState.error !== null) {
+ return (
+ <>
+ Could not check T3 Connect.
+ {settingsLink("Check T3 Connect")}
+ >
+ );
+ }
+ if (isSignedIn && reachability === "reachable") {
+ return (
+
+
+ Your phone can reach this computer. Sign in to the app with the same account.
+
+ );
+ }
+ if (isSignedIn && shared && reachability === undefined) {
+ return (
+
+ T3 Connect is on for this computer. Sign in to the app with the same account.
+
+ );
+ }
+ if (isSignedIn) {
+ // Signing in alone does not share this computer, for example after "Not now".
+ return (
+ <>
+
+ {shared
+ ? "T3 Connect is on, but the app cannot reach this computer."
+ : "Signed in. Share this computer so the app can reach it."}
+
+ {settingsLink(shared ? "Check T3 Connect" : "Set up T3 Connect")}
+ >
+ );
+ }
+ return (
+ <>
+
+ Sign in to T3 Connect here, then in the app with the same account.
+
+
+ Sign in to T3 Connect
+
+ >
+ );
+}
+
+// The ChatGPT card and the CLI card stay one card, so either choice can be undone here.
+function OnboardingCodexSetup({
+ environmentId,
+ provider,
+ serverConfig,
+ onUseCliChange,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly provider: ServerProvider;
+ readonly serverConfig: ServerConfig;
+ // True asks for the CLI's install or sign-in terminal once Codex has checked the CLI.
+ readonly onUseCliChange: (useCli: boolean) => void;
+}) {
+ const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
+ const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
+ const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
+ const instanceId = provider.instanceId;
+ const instance = serverConfig.settings.providerInstances[instanceId] ?? {
+ driver: provider.driver,
+ config: {},
+ };
+ const config =
+ instance.config !== null && typeof instance.config === "object" ? instance.config : {};
+ const mode = readCodexSetupMode(config);
+ const changeMode = (setupMode: "managed" | "existing") => {
+ // "Use existing CLI" also opens a terminal to install or sign in to the CLI.
+ onUseCliChange(setupMode === "existing");
+ const unchanged = mode === setupMode && provider.enabled;
+ if (unchanged || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
+ // One-instance upsert keeps a provider another client added in the meantime.
+ void persistProviderInstance({
+ operation: "upsert",
+ instanceId,
+ instance: { ...instance, enabled: true, config: { ...config, enabled: true, setupMode } },
+ }).then((result) => {
+ if (result._tag === "Failure") {
+ onUseCliChange(false);
+ if (!isAtomCommandInterrupted(result)) {
+ const cause = squashAtomCommandFailure(result);
+ toastManager.add({
+ type: "error",
+ title: "Could not save Codex setup",
+ ...(cause instanceof Error ? { description: cause.message } : {}),
+ });
+ }
+ }
+ });
+ };
+ return (
+
+ );
+}
+
+function TerminalAction({
+ name,
+ signIn,
+ disabled,
+ onClick,
+}: {
+ readonly name: string;
+ readonly signIn: boolean;
+ readonly disabled: boolean;
+ readonly onClick: () => void;
+}) {
+ const label = signIn ? "Sign in" : "Install";
+ return (
+
+
+ {label}
+
+ );
+}
+
+function AgentRow({
+ provider,
+ state,
+ action,
+ compact = false,
+}: {
+ readonly provider: ServerProvider;
+ readonly state: ReturnType;
+ readonly action?: ReactNode;
+ // A row in the "Add more agents" grid: the button carries the state, so no detail line.
+ readonly compact?: boolean;
+}) {
+ const name = agentName(provider);
+ // Claude Code and Codex tiles show Install or Set up; other agents that are on say why not.
+ const brokenMessage = isBrokenVersion(provider)
+ ? (provider.compatibilityAdvisory?.message ?? null)
+ : null;
+ const detail =
+ state === "install"
+ ? isInstallableDriver(provider.driver)
+ ? null
+ : "Not installed"
+ : compact || state === "ready" || state === "checking"
+ ? null
+ : state === "attention"
+ ? (provider.unavailableReason ??
+ brokenMessage ??
+ provider.message ??
+ getProviderSummary(provider).headline)
+ : (brokenMessage ?? getProviderSummary(provider).headline);
+ return (
+
+
+
+
+
+
+ {name}
+
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+
+
+ {state === "ready" ? (
+
+
+ Ready
+
+ ) : state === "checking" ? (
+ Checking…
+ ) : (
+ action
+ )}
+
+
+ );
+}
+
+function AgentInstallTerminal({
+ session,
+ onClose,
+}: {
+ readonly session: AgentTerminalSession;
+ readonly onClose: () => void;
+}) {
+ const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;
+ const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
+ const [advancedTypography] = useLocalStorage(
+ TYPOGRAPHY_ADVANCED_STORAGE_KEY,
+ false,
+ Schema.Boolean,
+ );
+ const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false });
+ const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false });
+ const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
+ const setupQueueRef = useRef(Promise.resolve());
+ const setupGenerationRef = useRef(0);
+ const activeSetupGenerationRef = useRef(null);
+ const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`);
+ const threadRef = scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID);
+ const [setupAttempt, setSetupAttempt] = useState(0);
+ const [setupState, setSetupState] = useState<
+ "preparing" | "ready" | "openFailed" | "writeFailed"
+ >("preparing");
+ const terminalReady = setupState === "ready" || setupState === "writeFailed";
+
+ // StrictMode can finish a cancelled open after replacement setup; it must not close or pre-type into that
+ // session.
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the setup PTY, types the command into it, and closes it on cleanup
+ useEffect(() => {
+ const generation = setupGenerationRef.current + 1;
+ setupGenerationRef.current = generation;
+ activeSetupGenerationRef.current = generation;
+ // oxlint-disable-next-line react/set-state-in-effect -- A retry or a new command reopens the terminal, which shows as preparing until the open settles.
+ setSetupState("preparing");
+
+ setupQueueRef.current = setupQueueRef.current.then(async () => {
+ if (activeSetupGenerationRef.current !== generation) return;
+ if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
+ setSetupState("openFailed");
+ return;
+ }
+ const opened = await openTerminal({
+ environmentId,
+ input: {
+ threadId: AGENT_ONBOARDING_THREAD_ID,
+ terminalId,
+ cwd,
+ providerInstanceId,
+ },
+ });
+ if (opened._tag !== "Success") {
+ if (activeSetupGenerationRef.current === generation) setSetupState("openFailed");
+ return;
+ }
+
+ if (activeSetupGenerationRef.current !== generation) return;
+ if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
+ setSetupState("writeFailed");
+ return;
+ }
+
+ const wrote = await writeTerminal({
+ environmentId,
+ input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command },
+ });
+ if (activeSetupGenerationRef.current !== generation) return;
+ setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");
+ });
+
+ // Revocation leaves the PTY running; close only while terminal access remains.
+ return () => {
+ if (activeSetupGenerationRef.current === generation) {
+ activeSetupGenerationRef.current = null;
+ }
+ setupQueueRef.current = setupQueueRef.current.then(async () => {
+ if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) return;
+ await closeTerminal({
+ environmentId,
+ input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },
+ });
+ });
+ };
+ }, [
+ closeTerminal,
+ command,
+ cwd,
+ environmentId,
+ openTerminal,
+ providerInstanceId,
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry bumps setupAttempt to open the terminal again.
+ setupAttempt,
+ terminalId,
+ writeTerminal,
+ ]);
+
+ return (
+
+
+
+ {!canOperateTerminal ? (
+ "This connection cannot control terminals."
+ ) : setupState === "writeFailed" ? (
+ <>
+ Run {command} in this
+ terminal.
+ >
+ ) : setupState === "ready" ? (
+ "Review the command, then press Enter to run it."
+ ) : setupState === "openFailed" ? (
+ "Could not open the setup terminal."
+ ) : (
+ "Preparing command..."
+ )}
+
+
+ {setupState === "openFailed" ? (
+ {
+ if (readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
+ setSetupAttempt((value) => value + 1);
+ }
+ }}
+ >
+ Retry
+
+ ) : null}
+
+ Close
+
+
+
+
+ {terminalReady ? (
+
+ ) : null}
+
+
+ );
+}
+
+const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history.";
+
+// Creates the selected projects, then imports each one's agent history. Owns retry state so a
+// second Start resumes instead of recreating projects.
+function useProjectImport({
+ scans,
+ importSelection,
+ onImportSelectionChange,
+ isImporting,
+ setIsImporting,
+ holdPageRef,
+ onDone,
+}: {
+ readonly scans: ReturnType;
+ readonly importSelection: WelcomeImportSelection | null;
+ readonly onImportSelectionChange: (next: WelcomeImportSelection) => void;
+ readonly isImporting: boolean;
+ readonly setIsImporting: (value: boolean) => void;
+ // Blocks leaving the page; onDone releases it before landing.
+ readonly holdPageRef: { current: boolean };
+ readonly onDone: (
+ projectRef?: ScopedProjectRef,
+ importWarning?: string,
+ importedThreadCount?: number,
+ ) => Promise;
+}) {
+ const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope);
+ const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
+ const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
+ const importWarningRef = useRef("");
+ // Per project, so a retry that skips finished projects still counts their threads.
+ const importedThreadsRef = useRef(new Map());
+ const importedProjectsRef = useRef(new Map());
+ const projectsWithImportedHistoryRef = useRef(new Map());
+ const lastImportSelectionRef = useRef>([]);
+ const projectAttemptsRef = useRef(
+ new Map(),
+ );
+ const importGenerationRef = useRef(0);
+
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- bumps the import generation on mount and unmount so in-flight imports drop stale results
+ useEffect(() => {
+ importGenerationRef.current += 1;
+ return () => {
+ importGenerationRef.current += 1;
+ };
+ }, []);
+
+ const { available: candidates, recent: recentActive } = partitionOnboardingProjects(
+ scans.flatMap((scan) =>
+ (scan.data?.candidates ?? []).map((candidate) => ({
+ ...candidate,
+ environmentId: scan.environmentId,
+ key: onboardingProjectKey(scan.environmentId, candidate.path),
+ })),
+ ),
+ );
+ // Projects already in T3 Code stay in the list, but reopening the page does not import their
+ // history again unless the user picks them. The scan can be 30 s old, so the live list decides.
+ const projects = useProjects();
+ const isNewProject = isNewOnboardingProject(projects);
+ const isNew = (candidate: ImportCandidate) => isNewProject(candidate.environmentId, candidate);
+ const recent = recentActive.filter(isNew);
+ const recentKeys = new Set(recent.map((candidate) => candidate.key));
+ const knownKeys = new Set(importSelection?.knownKeys);
+ const pickedKeys = new Set(importSelection?.keys);
+ const selected = importSelection?.optedOut
+ ? []
+ : candidates.filter((candidate) =>
+ knownKeys.has(candidate.key)
+ ? pickedKeys.has(candidate.key)
+ : recentKeys.has(candidate.key),
+ );
+ const selectedKeys = new Set(selected.map((candidate) => candidate.key));
+ const setSelectedKeys = (keys: ReadonlySet, { optedOut = false } = {}) =>
+ onImportSelectionChange({
+ knownKeys: candidates.map((candidate) => candidate.key),
+ keys: [...keys],
+ optedOut,
+ });
+
+ const canWrite = scans.every((scan) => writableEnvironments.has(scan.environmentId));
+ const canImport = selected.every((candidate) =>
+ writableEnvironments.has(candidate.environmentId),
+ );
+ const visibleImportError = canWrite && canImport ? "" : IMPORT_PERMISSION_MESSAGE;
+
+ const importedThreadTotal = () =>
+ lastImportSelectionRef.current.reduce(
+ (total, key) => total + (importedThreadsRef.current.get(key) ?? 0),
+ 0,
+ );
+
+ const finishAfterImport = () => {
+ const projectRef = resolveOnboardingLandingProject(
+ lastImportSelectionRef.current,
+ projectsWithImportedHistoryRef.current,
+ importedProjectsRef.current,
+ );
+ // Busy until setup is saved, so Start cannot run the import again meanwhile.
+ setIsImporting(true);
+ if (projectRef === undefined) {
+ void onDone(undefined, importWarningRef.current, importedThreadTotal()).then((completed) => {
+ if (!completed) setIsImporting(false);
+ });
+ return;
+ }
+ holdPageRef.current = true;
+ const importGeneration = importGenerationRef.current;
+ // waitForProject gives up after its timeout, so a project that never reaches the client
+ // still finishes setup, just without landing in it.
+ void waitForProject(projectRef)
+ .then(
+ () => projectRef,
+ () => undefined,
+ )
+ .then(async (landing) => {
+ if (importGeneration !== importGenerationRef.current) {
+ holdPageRef.current = false;
+ return;
+ }
+ const completed = await onDone(landing, importWarningRef.current, importedThreadTotal());
+ if (!completed) setIsImporting(false);
+ });
+ };
+
+ const runImport = async (selection: typeof candidates) => {
+ if (isImporting) return;
+ const hasAccess = () =>
+ selection.every((candidate) =>
+ readEnvironmentScope(candidate.environmentId, AuthOrchestrationOperateScope),
+ );
+ // The permission message shows from the session's scopes.
+ const stopForDeniedAccess = () => setIsImporting(false);
+ if (!hasAccess()) {
+ stopForDeniedAccess();
+ return;
+ }
+ setIsImporting(true);
+ importWarningRef.current = "";
+ lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
+ const importGeneration = importGenerationRef.current;
+ const importedThreads = importedThreadsRef.current;
+ const importedProjects = importedProjectsRef.current;
+ const projectAttempts = projectAttemptsRef.current;
+ let importedProjectsCount =
+ importedProjects.size > 0
+ ? selection.filter((candidate) => importedProjects.has(candidate.key)).length
+ : 0;
+ let skippedThreadCount = 0;
+ const refreshEnvironments = new Set();
+ // Resolves false when the run stopped early: a newer run, an unmount, or lost access.
+ const importEach = async () => {
+ for (const candidate of selection) {
+ const { environmentId } = candidate;
+ if (importGeneration !== importGenerationRef.current) return false;
+ if (!hasAccess()) {
+ stopForDeniedAccess();
+ return false;
+ }
+ if (importedProjects.has(candidate.key)) continue;
+ let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
+ if (projectId === null) {
+ let attempt = projectAttempts.get(candidate.key);
+ if (attempt === undefined) {
+ const nextProjectId = newProjectId();
+ attempt = {
+ projectId: nextProjectId,
+ commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),
+ };
+ projectAttempts.set(candidate.key, attempt);
+ }
+ projectId = attempt.projectId;
+ const result = await createProject({
+ environmentId,
+ input: {
+ projectId,
+ commandId: attempt.commandId,
+ title: candidate.title,
+ workspaceRoot: candidate.path,
+ createWorkspaceRootIfMissing: false,
+ defaultModelSelection: null,
+ },
+ });
+ if (importGeneration !== importGenerationRef.current) return false;
+ if (result._tag !== "Success") {
+ if (!isAtomCommandInterrupted(result)) {
+ projectAttempts.delete(candidate.key);
+ refreshEnvironments.add(environmentId);
+ }
+ continue;
+ }
+ }
+
+ if (!hasAccess()) {
+ stopForDeniedAccess();
+ return false;
+ }
+ const threadImportResult = await importThreads({
+ environmentId,
+ input: { projectId, expectedWorkspaceRoot: candidate.path },
+ });
+ if (importGeneration !== importGenerationRef.current) return false;
+ if (threadImportResult._tag === "Success") {
+ importedThreads.set(
+ candidate.key,
+ (importedThreads.get(candidate.key) ?? 0) + threadImportResult.value.importedCount,
+ );
+ skippedThreadCount += threadImportResult.value.skippedCount;
+ if (threadImportResult.value.importedCount > 0) {
+ projectsWithImportedHistoryRef.current.set(
+ candidate.key,
+ scopeProjectRef(environmentId, projectId),
+ );
+ }
+ if (threadImportResult.value.skippedCount === 0) {
+ importedProjectsCount += 1;
+ importedProjects.set(candidate.key, scopeProjectRef(environmentId, projectId));
+ }
+ } else if (!isAtomCommandInterrupted(threadImportResult)) {
+ projectAttempts.delete(candidate.key);
+ refreshEnvironments.add(environmentId);
+ }
+ }
+ return true;
+ };
+ holdPageRef.current = true;
+ const completed = await importEach().finally(() => {
+ holdPageRef.current = false;
+ });
+ if (!completed) return;
+ for (const scan of scans) {
+ if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
+ }
+ setIsImporting(false);
+ const importedThreadCount = importedThreadTotal();
+ if (importedProjectsCount < selection.length) {
+ if (importedThreadCount > 0 && skippedThreadCount > 0) {
+ importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`;
+ } else if (skippedThreadCount > 0) {
+ importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`;
+ } else if (importedThreadCount > 0) {
+ importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`;
+ } else {
+ importWarningRef.current = "Could not import thread history.";
+ }
+ }
+ finishAfterImport();
+ };
+
+ return {
+ canWrite,
+ candidates,
+ isNew,
+ recent,
+ selected,
+ selectedKeys,
+ setSelectedKeys,
+ visibleImportError,
+ runImport,
+ finishAfterImport,
+ };
+}
+
+type ImportCandidate = AgentSessionProjectCandidate & {
+ readonly environmentId: EnvironmentId;
+ readonly key: string;
+};
+
+function ImportCandidateList({
+ candidates,
+ selectedKeys,
+ onSelectionChange,
+}: {
+ readonly candidates: ReadonlyArray;
+ readonly selectedKeys: ReadonlySet;
+ readonly onSelectionChange: (next: ReadonlySet) => void;
+}) {
+ const { repositories, other } = groupOnboardingProjects(candidates);
+ const setKeys = (keys: ReadonlyArray, checked: boolean) => {
+ const next = new Set(selectedKeys);
+ for (const key of keys) {
+ if (checked) next.add(key);
+ else next.delete(key);
+ }
+ onSelectionChange(next);
+ };
+ const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length;
+
+ return (
+ <>
+ {repositories.map((group) => (
+
+ ))}
+ {other.length > 0 ? (
+
+
+ 0 && otherSelected < other.length}
+ onCheckedChange={(checked) =>
+ setKeys(
+ other.map((candidate) => candidate.key),
+ checked === true,
+ )
+ }
+ />
+
+
+ Other folders
+
+ {other.length} {other.length === 1 ? "folder" : "folders"}
+
+
+
+
+ {other.map((candidate) => (
+ setKeys([candidate.key], checked)}
+ />
+ ))}
+
+
+ ) : null}
+ >
+ );
+}
+
+function ImportRepositoryGroup({
+ group,
+ selectedKeys,
+ onToggle,
+}: {
+ readonly group: OnboardingProjectGroup;
+ readonly selectedKeys: ReadonlySet;
+ readonly onToggle: (keys: ReadonlyArray, checked: boolean) => void;
+}) {
+ const keys = group.candidates.map((candidate) => candidate.key);
+ const selectedCount = keys.filter((key) => selectedKeys.has(key)).length;
+ const single = group.candidates.length === 1;
+ const only = group.candidates[0];
+ if (single && only !== undefined) {
+ return (
+ onToggle([only.key], checked)}
+ />
+ );
+ }
+ return (
+
+
+ 0 && selectedCount < keys.length}
+ onCheckedChange={(checked) => onToggle(keys, checked === true)}
+ />
+
+
+ {group.label}
+ c.sources))]}
+ threadCount={group.threadCount}
+ lastActiveAt={group.lastActiveAt}
+ />
+
+
+
+ {group.candidates.map((candidate) => (
+ onToggle([candidate.key], checked)}
+ />
+ ))}
+
+
+ );
+}
+
+function ProjectPathText({
+ path,
+ className,
+}: {
+ readonly path: string;
+ readonly className?: string;
+}) {
+ const { name, parent } = splitOnboardingProjectPath(path);
+ return (
+
+ {parent.length > 0 ? {parent} : null}
+ {name}
+
+ );
+}
+
+function ImportCandidateRow({
+ candidate,
+ label,
+ path,
+ nested = false,
+ checked,
+ onCheckedChange,
+}: {
+ readonly candidate: ImportCandidate;
+ readonly label?: string;
+ readonly path?: string;
+ readonly nested?: boolean;
+ readonly checked: boolean;
+ readonly onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+ onCheckedChange(value === true)} />
+
+ }
+ >
+ {label !== undefined ? (
+ {label}
+ ) : null}
+ {path !== undefined ? (
+
+ ) : null}
+
+ {candidate.path}
+
+
+
+ );
+}
+
+const IMPORT_SOURCE_NAMES: Record = {
+ claudeAgent: "Claude Code",
+ codex: "Codex",
+ pi: "Pi",
+};
+const IMPORT_SOURCES_LABEL = new Intl.ListFormat("en", { type: "conjunction" }).format(
+ AgentSessionSource.literals.map((source) => IMPORT_SOURCE_NAMES[source]),
+);
+
+function ImportRowMeta({
+ sources,
+ threadCount,
+ lastActiveAt,
+}: {
+ readonly sources: ReadonlyArray | null;
+ readonly threadCount: number;
+ readonly lastActiveAt: string | null;
+}) {
+ const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt);
+ const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value;
+ return (
+
+ {AgentSessionSource.literals.map((source) => (
+
+ {sources?.includes(source) ? (
+
+
+
+ ) : null}
+
+ ))}
+ {threadCount}
+ {age}
+
+ );
+}
+
+/** The desktop app links `t3` on a fresh install; this puts its folder on PATH when it is not. */
+function CliPathSetup() {
+ const { state, busy, run } = useDesktopCliCommand();
+ const setup = state?.pathSetup;
+ if (!setup) return null;
+ return (
+
+
+
+
+
+ {setup.added ? (
+ <>
+ Added to {setup.file}. Open a new terminal to use t3.
+ >
+ ) : (
+ <>
+ Use t3 from any terminal. Adds one line to{" "}
+ {setup.file}.
+ >
+ )}
+
+ {setup.added ? null : (
+
run("addToPath")}>
+ Add to PATH
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx
deleted file mode 100644
index c55691d9ca5cc..0000000000000
--- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx
+++ /dev/null
@@ -1,388 +0,0 @@
-import { RegistryContext } from "@effect/atom-react";
-import {
- AuthOrchestrationOperateScope,
- AuthOrchestrationReadScope,
- EnvironmentId,
- ProjectId,
- type AgentSessionProjectCandidate,
- type AuthEnvironmentScope,
- type AuthSessionState,
-} from "@t3tools/contracts";
-import { AsyncResult, Atom } from "effect/reactivity";
-import { expect, it, vi } from "vite-plus/test";
-
-import "~/index.css";
-import { act, render } from "../../../test/render";
-
-type SessionResult = AsyncResult.AsyncResult;
-type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string };
-type CreateProjectInput = {
- environmentId: EnvironmentId;
- input: { projectId: ProjectId; workspaceRoot: string };
-};
-const state = vi.hoisted(() => ({
- selectedEnvironment: "primary",
- addedEnvironments: [] as string[],
- sessions: new Map(),
- projects: null as unknown,
- providers: null as unknown,
- config: null as unknown,
- candidates: [] as AgentSessionProjectCandidate[],
- createProject: vi.fn(),
- importThreads: vi.fn(),
- refreshProviders: vi.fn(),
- connectPairing: vi.fn(),
- completeOnboarding: vi.fn(),
- onDone: vi.fn(),
- scan: vi.fn(),
- refreshScan: vi.fn(),
- waitForProject: vi.fn(),
-}));
-
-vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => {
- const actual = await original();
- return {
- ...actual,
- createEnvironmentSessionAtoms: ((runtime: never) => ({
- ...actual.createEnvironmentSessionAtoms(runtime),
- sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id),
- })) as never,
- };
-});
-vi.mock(import("../../cloud/publicConfig"), async (original) => ({
- ...(await original()),
- hasCloudPublicConfig: () => false,
-}));
-vi.mock(import("../../onboarding/firstRun"), async (original) => ({
- ...(await original()),
- completeOnboarding: state.completeOnboarding,
-}));
-vi.mock(import("../../state/environments"), async (original) => {
- const environment = (environmentId: string) => ({
- environmentId,
- label: environmentId,
- connection: { phase: "connected" },
- entry: { enabled: true, target: { _tag: "DirectConnectionTarget" } },
- });
- const primary = environment("primary");
- return {
- ...(await original()),
- usePrimaryEnvironment: (() => primary) as never,
- useEnvironments: (() => ({
- environments: [state.selectedEnvironment, ...state.addedEnvironments].map(environment),
- })) as never,
- };
-});
-vi.mock(import("../../state/server"), async (original) => {
- const actual = await original();
- return {
- ...actual,
- serverEnvironment: {
- ...actual.serverEnvironment,
- providersValueAtom: (() => state.providers) as never,
- configValueAtom: (() => state.config) as never,
- refreshProviders: "refreshProviders" as never,
- },
- };
-});
-vi.mock(import("../../state/entities"), async (original) => {
- const { useAtomValue } = await import("@effect/atom-react");
- return {
- ...(await original()),
- useProjects: (() => useAtomValue(state.projects as Atom.Atom)) as never,
- readProjects: (() => appAtomRegistry.get(state.projects as Atom.Atom)) as never,
- waitForProject: ((ref: unknown) => state.waitForProject(ref)) as never,
- };
-});
-vi.mock(import("../../state/projects"), async (original) => ({
- ...(await original()),
- projectEnvironment: { create: "createProject" } as never,
-}));
-vi.mock(import("../../state/agentSessions"), async (original) => ({
- ...(await original()),
- agentSessionImport: "importThreads" as never,
-}));
-vi.mock(import("../../onboarding/useProjectScans"), async (original) => ({
- ...(await original()),
- useProjectScans: (ids: readonly EnvironmentId[]) =>
- ids.map((environmentId) => {
- state.scan({ environmentId });
- return {
- environmentId,
- data: { candidates: state.candidates, truncated: false } as never,
- isPending: false,
- error: null,
- refresh: state.refreshScan,
- };
- }),
-}));
-vi.mock(import("../../connection/onboarding"), async (original) => ({
- ...(await original()),
- connectPairing: "connectPairing" as never,
-}));
-vi.mock(import("../../state/use-atom-command"), async (original) => ({
- ...(await original()),
- useAtomCommand: ((
- command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing",
- ) => state[command]) as never,
-}));
-vi.mock(import("../settings/ChatGptWelcomeCoordinator"), async (original) => ({
- ...(await original()),
- ChatGptWelcomeCoordinator: () => <>>,
-}));
-
-import { appAtomRegistry } from "../../rpc/atomRegistry";
-import { WelcomeWizard } from "./WelcomeWizard";
-
-const primaryId = EnvironmentId.make("primary");
-const remoteId = EnvironmentId.make("remote");
-const permissionMessage = "This connection cannot import projects or thread history.";
-
-function session(scopes: ReadonlyArray): AuthSessionState {
- return {
- authenticated: true,
- scopes: [AuthOrchestrationReadScope, ...scopes],
- auth: {
- policy: "remote-reachable",
- bootstrapMethods: ["one-time-token"],
- sessionMethods: ["bearer-access-token"],
- sessionCookieName: "t3_session",
- },
- };
-}
-
-function sessionAtom(environmentId: EnvironmentId) {
- return state.sessions.get(environmentId) as Atom.Writable;
-}
-
-function projectsAtom() {
- return state.projects as Atom.Writable>;
-}
-
-function setGrant(environmentId: EnvironmentId, allowed: boolean) {
- appAtomRegistry.set(
- sessionAtom(environmentId),
- AsyncResult.success(session(allowed ? [AuthOrchestrationOperateScope] : [])),
- );
-}
-
-// Default selection only picks recently active projects, so pin "now".
-const NOW = Date.parse("2026-01-01T00:00:00.000Z");
-
-function candidate(path: string): AgentSessionProjectCandidate {
- return {
- path,
- title: path.split("/").at(-1)!,
- sources: ["codex"],
- threadCount: 10,
- lastActiveAt: new Date(NOW - 60_000).toISOString(),
- alreadyImported: false,
- };
-}
-
-function recordProject({ environmentId, input }: CreateProjectInput) {
- appAtomRegistry.set(projectsAtom(), [
- ...appAtomRegistry.get(projectsAtom()),
- { id: input.projectId, environmentId, workspaceRoot: input.workspaceRoot },
- ]);
- return AsyncResult.success(undefined);
-}
-
-// Resolves once the project reaches the mocked projects atom; the real helper's timeout is
-// replaced per test with a rejection.
-function waitForRecordedProject(ref: { environmentId: EnvironmentId; projectId: string }) {
- const find = () =>
- appAtomRegistry
- .get(projectsAtom())
- .find(
- (project) => project.id === ref.projectId && project.environmentId === ref.environmentId,
- );
- return new Promise((resolve) => {
- const current = find();
- if (current) return resolve(current);
- const unsubscribe = appAtomRegistry.subscribe(projectsAtom(), () => {
- const project = find();
- if (!project) return;
- unsubscribe();
- resolve(project);
- });
- });
-}
-
-function deferred() {
- let resolve!: (value: T) => void;
- const promise = new Promise((complete) => {
- resolve = complete;
- });
- return { promise, resolve };
-}
-
-function pageText() {
- return document.body.textContent ?? "";
-}
-
-function button(label: string) {
- const found = [...document.querySelectorAll("button")].find(
- (element) => element.textContent?.trim() === label,
- );
- if (!found) throw new Error(`Missing button: ${label}`);
- return found;
-}
-
-async function click(label: string) {
- const target = button(label);
- expect(target.disabled).toBe(false);
- await act(async () => target.click());
-}
-
-async function clickStage(label: string) {
- const stage = document.querySelector(`button[aria-label="${label}"]`);
- expect(stage?.disabled).toBe(false);
- await act(async () => stage?.click());
-}
-
-async function mountImport(remote = false) {
- state.selectedEnvironment = remote ? "remote" : "primary";
- await using stack = new AsyncDisposableStack();
- const screen = stack.use(
- await render(
-
-
- ,
- ),
- );
- await click("Continue");
- await click("Continue");
- expect(pageText()).toContain("Import your projects");
- stack.move();
- return screen;
-}
-
-// The mocked modules read `state`, so every test starts by replacing all of it.
-// Returns the clock spy so each test can `using` it; the spy would otherwise outlive the test.
-function setup() {
- const now = vi.spyOn(Date, "now").mockReturnValue(NOW);
- state.addedEnvironments = [];
- state.sessions.clear();
- for (const id of [primaryId, remoteId]) {
- state.sessions.set(
- id,
- Atom.make(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe(
- Atom.keepAlive,
- ),
- );
- }
- state.projects = Atom.make>([]).pipe(Atom.keepAlive);
- state.providers = Atom.make>([]).pipe(Atom.keepAlive);
- state.config = Atom.make(null).pipe(Atom.keepAlive);
- state.candidates = [candidate("/projects/first"), candidate("/projects/second")];
- state.createProject
- .mockReset()
- .mockImplementation(async (input: CreateProjectInput) => recordProject(input));
- state.importThreads
- .mockReset()
- .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 }));
- state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined));
- state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId));
- state.completeOnboarding.mockReset().mockResolvedValue(undefined);
- state.onDone.mockReset();
- state.scan.mockReset();
- state.refreshScan.mockReset();
- state.waitForProject.mockReset().mockImplementation(waitForRecordedProject);
- return now;
-}
-
-it("keeps the project selection after going back a stage", async () => {
- using _now = setup();
- await using _screen = await mountImport();
- await click("Select none");
- expect(pageText()).toContain("0 of 2 selected");
-
- await clickStage("Agents, step 2");
- await click("Continue");
- expect(pageText()).toContain("Import your projects");
- expect(pageText()).toContain("0 of 2 selected");
-});
-
-it("ticks the recent projects of a computer added after the selection changed", async () => {
- using _now = setup();
- await using _screen = await mountImport();
- await click("Select none");
-
- state.addedEnvironments = ["remote"];
- await clickStage("Connect, step 1");
- await click("Continue");
- await click("Continue");
- expect(pageText()).toContain("2 of 4 selected");
-
- await click("Import 2 projects");
- const created = state.createProject.mock.calls as Array<[CreateProjectInput]>;
- expect(created.map(([call]) => call.environmentId)).toEqual([remoteId, remoteId]);
-});
-
-it("rechecks after project creation and can retry its history without recreating the project", async () => {
- using _now = setup();
- const creation = deferred();
- state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => {
- await creation.promise;
- return recordProject(input);
- });
- await using _screen = await mountImport();
- await click("Import 2 projects");
- expect(state.createProject).toHaveBeenCalledOnce();
- await act(async () => {
- setGrant(primaryId, false);
- creation.resolve();
- await creation.promise;
- });
- expect(state.importThreads).not.toHaveBeenCalled();
- expect(state.createProject).toHaveBeenCalledOnce();
- expect(state.onDone).not.toHaveBeenCalled();
- expect(pageText()).toContain(permissionMessage);
-
- await act(async () => setGrant(primaryId, true));
- await click("Import 2 projects");
- expect(state.createProject).toHaveBeenCalledTimes(2);
- expect(state.importThreads).toHaveBeenCalledTimes(2);
- expect(state.onDone).toHaveBeenCalledOnce();
-});
-
-it("completes accepted imports when permission is revoked after the final server write", async () => {
- using _now = setup();
- state.candidates = state.candidates.slice(0, 1);
- const imported = deferred<{ importedCount: number; skippedCount: number }>();
- state.importThreads.mockImplementationOnce(async () =>
- AsyncResult.success(await imported.promise),
- );
- await using _screen = await mountImport();
- await click("Import 1 project");
- await act(async () => {
- setGrant(primaryId, false);
- imported.resolve({ importedCount: 1, skippedCount: 0 });
- await imported.promise;
- });
- expect(state.createProject).toHaveBeenCalledOnce();
- expect(state.importThreads).toHaveBeenCalledOnce();
- expect(state.completeOnboarding).toHaveBeenCalledOnce();
- expect(state.onDone).toHaveBeenCalledOnce();
-});
-
-it("finishes setup without landing when the imported project never reaches the client", async () => {
- using _now = setup();
- state.candidates = state.candidates.slice(0, 1);
- // The server accepts the project, but it never shows up in the client's projects.
- state.createProject.mockResolvedValue(AsyncResult.success(undefined));
- state.waitForProject.mockRejectedValue(new Error("The project did not appear."));
- state.completeOnboarding.mockRejectedValueOnce(new Error("offline"));
- await using _screen = await mountImport();
-
- await click("Import 1 project");
- await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce());
- // A failed completion hands the step back instead of leaving it importing.
- await vi.waitFor(() => expect(button("Import 1 project").disabled).toBe(false));
- expect(state.onDone).not.toHaveBeenCalled();
-
- await click("Import 1 project");
- await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
- expect(state.onDone).toHaveBeenCalledWith(undefined);
-});
diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx
deleted file mode 100644
index c4647e7cf282a..0000000000000
--- a/apps/web/src/components/onboarding/WelcomeWizard.tsx
+++ /dev/null
@@ -1,1845 +0,0 @@
-import { useAuth } from "@clerk/react";
-import { useAtomValue } from "@effect/atom-react";
-import type {
- AgentSessionProjectCandidate,
- EnvironmentId,
- ProjectId,
- ProviderInstanceId,
- ScopedProjectRef,
- ServerConfig,
- ServerProvider,
-} from "@t3tools/contracts";
-import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
-import {
- isAtomCommandInterrupted,
- squashAtomCommandFailure,
-} from "@t3tools/client-runtime/state/runtime";
-import {
- AgentSessionSource,
- AuthTerminalOperateScope,
- CommandId,
- defaultInstanceIdForDriver,
- AuthOrchestrationOperateScope,
- isManagedDriverAllowed,
- ProviderDriverKind,
- ThreadId,
-} from "@t3tools/contracts";
-import * as Schema from "effect/Schema";
-import {
- ArrowRightIcon,
- CheckIcon,
- ChevronRightIcon,
- CloudIcon,
- LinkIcon,
- MonitorIcon,
- TerminalIcon,
-} from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-
-import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
-import { useLocalStorage } from "../../hooks/useLocalStorage";
-import { hasCloudPublicConfig } from "../../cloud/publicConfig";
-import { PRIVACY_POLICY_URL } from "../../legalLinks";
-import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
-import { completeOnboarding } from "../../onboarding/firstRun";
-import {
- groupOnboardingProjects,
- partitionOnboardingProjects,
- onboardingProjectKey,
- resolveOnboardingLandingProject,
- resolveOnboardingProjectId,
- type OnboardingProjectGroup,
- splitOnboardingProjectPath,
-} from "../../onboarding/projectImport.logic";
-import {
- getOnboardingProviderState,
- resolveOnboardingProviderInstallCommand,
- resolveOnboardingProviderLoginCommand,
- selectOnboardingProvidersByDriver,
-} from "../../onboarding/providerReadiness.logic";
-import { newProjectId, randomUUID } from "../../lib/utils";
-import { agentSessionImport } from "../../state/agentSessions";
-import { readProjects, waitForProject } from "../../state/entities";
-import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
-import {
- managedInstanceLocks,
- managedTelemetryOffMessage,
- usePrimaryManagedPolicy,
-} from "../../state/managedPolicy";
-import {
- isOnboardingRelayEnvironment,
- resolveOnboardingSetup,
-} from "../../onboarding/targetEnvironment.logic";
-import { useProjectScans } from "../../onboarding/useProjectScans";
-import { projectEnvironment } from "../../state/projects";
-import { serverEnvironment } from "../../state/server";
-import {
- readEnvironmentScope,
- useEnvironmentScope,
- useEnvironmentsWithScope,
-} from "../../state/session";
-import { terminalEnvironment } from "../../state/terminal";
-import { useAtomCommand } from "../../state/use-atom-command";
-import { connectPairing } from "../../connection/onboarding";
-import { getProviderSummary } from "../settings/providerStatus";
-import { providerClients } from "../settings/providerDriverMeta";
-import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator";
-import { useDesktopCliCommand } from "../settings/CliCommandSettingsRow";
-import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection";
-import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";
-import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";
-import { TerminalViewport } from "../ThreadTerminalDrawer";
-import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
-import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation";
-import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
-import { T3Wordmark } from "../T3Wordmark";
-import { Alert, AlertDescription } from "../ui/alert";
-import { Button } from "../ui/button";
-import { InstallT3Commands } from "../InstallT3Commands";
-import { Checkbox } from "../ui/checkbox";
-import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
-import { Input } from "../ui/input";
-import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
-import { ScrollArea } from "../ui/scroll-area";
-import { Spinner } from "../ui/spinner";
-import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard";
-import { Dialog } from "../ui/dialog";
-import { toastManager } from "../ui/toast";
-import { cn } from "../../lib/utils";
-import { formatRelativeTime } from "../../timestampFormat";
-
-type WizardStep = "connection" | "agents" | "import";
-const NO_ENVIRONMENTS: readonly EnvironmentId[] = [];
-
-const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup");
-const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const;
-const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing.";
-
-export function WelcomeWizard({
- localAvailable,
- onDone,
- resumeEnvironmentId,
-}: {
- readonly localAvailable: boolean;
- readonly resumeEnvironmentId?: EnvironmentId | undefined;
- readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise;
-}) {
- const [step, setStep] = useState(resumeEnvironmentId ? "agents" : "connection");
- const { environments } = useEnvironments();
- const [selection, setSelection] = useState | null>(null);
- // Share the registry for the wizard's lifetime so unchecked computers are not auto-selected again.
- const [autoSelectedComputers] = useState(() => new Set());
- const [setupIds, setSetupIds] = useState(
- resumeEnvironmentId ? [resumeEnvironmentId] : [],
- );
- const [importSelection, setImportSelection] = useState(null);
- const [isImporting, setIsImporting] = useState(false);
- const finishingPromiseRef = useRef | null>(null);
- const completionErrorToastIdRef = useRef | null>(null);
- const primaryEnvironment = usePrimaryEnvironment();
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- marks newly listed computers in the shared auto-select registry once and selects the enabled ones
- useEffect(() => {
- const newComputers = environments.filter(
- (environment) => !autoSelectedComputers.has(environment.environmentId),
- );
- if (newComputers.length === 0) return;
- for (const environment of newComputers) {
- autoSelectedComputers.add(environment.environmentId);
- }
- const enabledComputers = newComputers.filter((environment) => environment.entry.enabled);
- if (enabledComputers.length === 0) return;
- // oxlint-disable-next-line react/set-state-in-effect -- The registry marks each computer once, and only after commit; render must not mutate it.
- setSelection(
- (current) =>
- new Set([
- ...(current ?? []),
- ...enabledComputers.map((environment) => environment.environmentId),
- ]),
- );
- }, [autoSelectedComputers, environments]);
- const selectedIds =
- selection ?? new Set(primaryEnvironment ? [primaryEnvironment.environmentId] : []);
- const scans = useProjectScans(step === "import" ? setupIds : NO_ENVIRONMENTS);
- const isLoadingProjects =
- step === "import" &&
- scans.every((scan) => scan.data === null) &&
- scans.some((scan) => scan.isPending);
- const startSetup = (ids: readonly EnvironmentId[]) => {
- if (ids.length === 0) return;
- setSetupIds(ids);
- setStep("agents");
- };
- const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
- const finish = (
- projectRef?: ScopedProjectRef,
- importWarning?: string,
- importedThreadCount = 0,
- ) => {
- if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
- if (completionErrorToastIdRef.current !== null) {
- toastManager.close(completionErrorToastIdRef.current);
- completionErrorToastIdRef.current = null;
- }
-
- const completion = completeOnboarding()
- .then(async () => {
- if (completionErrorToastIdRef.current !== null) {
- toastManager.close(completionErrorToastIdRef.current);
- completionErrorToastIdRef.current = null;
- }
- await onDone(projectRef);
- if (importWarning) {
- toastManager.add({
- type: "warning",
- title: "Some history was not imported",
- description: importWarning,
- timeout: 0,
- });
- } else if (importedThreadCount > 0) {
- toastManager.add({
- type: "success",
- title: `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}`,
- });
- }
- return true;
- })
- .catch(() => {
- const errorToast = {
- type: "error",
- title: "Could not finish setup",
- description: "Your settings could not be saved. Try again.",
- } as const;
- if (completionErrorToastIdRef.current === null) {
- completionErrorToastIdRef.current = toastManager.add(errorToast);
- } else {
- toastManager.update(completionErrorToastIdRef.current, errorToast);
- }
- return false;
- })
- .finally(() => {
- if (finishingPromiseRef.current === completion) {
- finishingPromiseRef.current = null;
- }
- });
- finishingPromiseRef.current = completion;
- return completion;
- };
-
- return (
- {
- event.cancel();
- if (event.reason === "close-press" && !isImporting) void finish();
- }}
- >
- document.getElementById("onboarding-pairing-url") ?? true}
- >
-
-
-
- Code
-
-
- }
- >
- isImporting || index >= stageIndex}
- onStepChange={(index) => {
- if (isImporting || index > stageIndex) return;
- setStep(index === 0 ? "connection" : "agents");
- }}
- />
-
-
-
- {step === "connection" ? (
-
- setSelection((current) => {
- const next = new Set(current ?? selectedIds);
- if (checked) next.add(environmentId);
- else next.delete(environmentId);
- return next;
- })
- }
- onContinue={() =>
- startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds)
- }
- onPaired={(environmentId) => {
- setSelection(new Set([...selectedIds, environmentId]));
- }}
- />
- ) : step === "agents" ? (
- setStep("import")} />
- ) : (
-
- )}
-
-
-
-
- );
-}
-
-function ConnectionStep({
- autoSelectedComputers,
- expandPairingInitially,
- selectedIds,
- onSelectionChange,
- onToggleEnvironment,
- onContinue,
- onPaired,
-}: {
- readonly autoSelectedComputers: Set;
- readonly expandPairingInitially: boolean;
- readonly selectedIds: ReadonlySet;
- readonly onSelectionChange: (ids: ReadonlySet) => void;
- readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
- readonly onContinue: () => void;
- readonly onPaired: (environmentId: EnvironmentId) => void;
-}) {
- const { environments } = useEnvironments();
- const telemetryOff = managedTelemetryOffMessage(usePrimaryManagedPolicy());
- const cloudEnabled = hasCloudPublicConfig();
- const directEnvironments = environments.filter(
- (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment),
- );
- const [pairingOpen, setPairingOpen] = useState(expandPairingInitially);
- const [isPairing, setIsPairing] = useState(false);
- const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds);
- const skippedLabels = environments
- .filter((environment) => skippedIds.includes(environment.environmentId))
- .map((environment) => environment.label);
- const continueRef = useRef(null);
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves DOM focus to the Continue button once setup is ready and nothing else holds focus
- useEffect(() => {
- if (
- ready &&
- (document.activeElement === document.body ||
- document.activeElement?.getAttribute("role") === "dialog")
- ) {
- continueRef.current?.focus();
- }
- }, [ready]);
- return (
- <>
-
- Connect your computers
-
-
- Choose one or more computers. We’ll set up agents and projects on each.
-
- {directEnvironments.length > 0 ? (
-
- Computers to set up
- {directEnvironments.map((environment) => (
-
- {
- const next = new Set(selectedIds);
- if (checked) next.add(environment.environmentId);
- else next.delete(environment.environmentId);
- onSelectionChange(next);
- }}
- />
-
-
-
-
- {environment.label}
-
-
- {!environment.entry.enabled && environment.connection.phase !== "unsupported"
- ? "Off"
- : presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
-
-
- {environment.displayUrl ? (
-
- {environment.displayUrl}
-
- ) : null}
- {environment.connection.phase === "unsupported" && environment.connection.error ? (
-
- {environment.connection.error}
-
- ) : null}
-
-
- ))}
-
- ) : null}
-
- {cloudEnabled ? (
-
- ) : null}
-
-
-
- }
- >
-
- Add a computer
-
-
-
-
-
{
- setPairingOpen(false);
- onPaired(environmentId);
- requestAnimationFrame(() => continueRef.current?.focus());
- }}
- />
-
-
-
-
-
- {skippedLabels.length > 0 ? (
-
- Not connected, so setup skips {skippedLabels.join(", ")}. You can set{" "}
- {skippedLabels.length === 1 ? "it" : "them"} up later from Settings.
-
- ) : null}
-
-
- {telemetryOff ?? "T3 Code collects anonymous usage data to help us improve it."} To read
- more about how your data is used{telemetryOff === undefined ? " and how to opt out" : ""},
- see our{" "}
-
- privacy policy
-
- .
-
-
- Continue
-
-
-
- >
- );
-}
-
-function ConnectAccountOption({
- autoSelectedComputers,
- disabled,
- selectedIds,
- onToggleEnvironment,
-}: {
- readonly autoSelectedComputers: Set;
- readonly disabled: boolean;
- readonly selectedIds: ReadonlySet;
- readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
-}) {
- const { environments } = useEnvironments();
- const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
- const { openAuthPrompt } = useT3ConnectAuthPrompt();
- const [expanded, setExpanded] = useState(true);
- const [discoveryReady, setDiscoveryReady] = useState(false);
- const onDiscoveryReady = () => setDiscoveryReady(true);
-
- return (
-
-
- {
- if (!isSignedIn) {
- event.preventDefault();
- setExpanded(true);
- openAuthPrompt();
- }
- }}
- render={
-
- }
- >
-
- T3 Connect
-
- {!isLoaded
- ? "Loading sign-in…"
- : !isSignedIn
- ? "Sign in"
- : !discoveryReady
- ? "Loading computers…"
- : null}
-
-
-
-
-
-
- {isSignedIn ? (
- No computers linked yet.
- }
- />
- ) : null}
-
-
- Run this on each computer you want to connect.
-
-
-
- Keep T3 Code running. Select the computers you want to set up above.
-
-
-
-
-
- );
-}
-
-function PairingForm({
- isPairing,
- setIsPairing,
- onPaired,
-}: {
- readonly isPairing: boolean;
- readonly setIsPairing: (value: boolean) => void;
- readonly onPaired: (environmentId: EnvironmentId) => void;
-}) {
- const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false });
- const [pairingUrl, setPairingUrl] = useState("");
- const [errorMessage, setErrorMessage] = useState("");
- const mountedRef = useRef(true);
-
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tracks mount so a pairing request that settles after unmount does not set state
- useEffect(() => {
- mountedRef.current = true;
- return () => {
- mountedRef.current = false;
- };
- }, []);
-
- const submit = async () => {
- if (isPairing || pairingUrl.trim().length === 0) return;
- setIsPairing(true);
- setErrorMessage("");
- const result = await connectPairingEnvironment({ pairingUrl: pairingUrl.trim() });
- if (!mountedRef.current) return;
- setIsPairing(false);
- if (result._tag === "Success") {
- onPaired(result.value);
- return;
- }
- if (isAtomCommandInterrupted(result)) return;
- const cause = squashAtomCommandFailure(result);
- setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed.");
- };
-
- return (
- <>
-
- >
- );
-}
-
-const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const;
-type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];
-
-interface AgentTerminalSession {
- readonly environmentId: EnvironmentId;
- readonly driver: OnboardingAgentDriver;
- readonly providerInstanceId: ServerProvider["instanceId"];
- readonly cwd: string;
- readonly command: string;
- readonly keybindings: ServerConfig["keybindings"];
-}
-
-function AgentsStep({
- environmentIds,
- onContinue,
-}: {
- readonly environmentIds: readonly EnvironmentId[];
- readonly onContinue: () => void;
-}) {
- const { environments } = useEnvironments();
- return (
-
-
-
- {environmentIds.map((environmentId) => (
- environment.environmentId === environmentId)
- ?.label ?? "Computer"
- }
- />
- ))}
-
-
-
-
-
- );
-}
-
-function ConnectedAgentsStep({
- environmentId,
- machineLabel,
-}: {
- readonly environmentId: EnvironmentId;
- readonly machineLabel: string;
-}) {
- const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
- const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
- reportFailure: false,
- });
- const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
- const [terminalSession, setTerminalSession] = useState(null);
- const [addingAccount, setAddingAccount] = useState(false);
- const [createdAccount, setCreatedAccount] = useState<{
- instanceId: ProviderInstanceId;
- displayName: string;
- autoStart: boolean;
- } | null>(null);
- const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
-
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- asks the environment to refresh its provider list when the environment changes
- useEffect(() => {
- void refreshProviders({ environmentId, input: {} });
- }, [environmentId, refreshProviders]);
-
- const byDriver = selectOnboardingProvidersByDriver(providers);
- const managedPolicy = serverConfig?.managedPolicy;
- // A policy that owns Codex's launch settings also rules out ChatGPT's own runtime.
- const chatGptAllowed =
- managedInstanceLocks(managedPolicy, ProviderDriverKind.make("codex"))?.configKeys.has(
- "setupMode",
- ) !== true;
-
- const primaryAgents = PRIMARY_AGENT_DRIVERS.filter((driver) =>
- isManagedDriverAllowed(managedPolicy, ProviderDriverKind.make(driver)),
- ).flatMap((driver) => {
- const instances =
- driver === "codex" ? providers?.filter((provider) => provider.driver === driver) : undefined;
- return instances?.length
- ? instances.map((provider) => ({ driver, provider, instanceId: provider.instanceId }))
- : [{ driver, provider: byDriver.get(driver), instanceId: byDriver.get(driver)?.instanceId }];
- });
- if (createdAccount) {
- const index = primaryAgents.findIndex(
- (agent) => agent.instanceId === createdAccount.instanceId,
- );
- const [existing] = index >= 0 ? primaryAgents.splice(index, 1) : [];
- primaryAgents.unshift(
- existing ?? {
- driver: "codex",
- provider: undefined,
- instanceId: createdAccount.instanceId,
- },
- );
- }
- return (
-
- {machineLabel}
- {!canOperateTerminal ? (
-
- This connection cannot control terminals.
-
- ) : null}
-
- {primaryAgents.map(({ driver, provider, instanceId }) =>
- driver === "codex" && serverConfig !== null ? (
-
- setCreatedAccount((account) => (account ? { ...account, autoStart: false } : null))
- }
- terminalOpen={terminalSession?.driver === driver}
- onOpenTerminal={() => {
- if (
- provider === undefined ||
- !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
- )
- return;
- setTerminalSession({
- environmentId,
- driver,
- providerInstanceId: provider.instanceId,
- cwd: serverConfig.cwd,
- command: provider.installed
- ? resolveOnboardingProviderLoginCommand(
- provider,
- serverConfig.settings,
- serverConfig.environment.platform.os,
- )
- : resolveOnboardingProviderInstallCommand(
- driver,
- serverConfig.environment.platform.os,
- ),
- keybindings: serverConfig.keybindings,
- });
- }}
- />
- ) : (
- {
- if (
- provider === undefined ||
- serverConfig === null ||
- !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
- )
- return;
- setTerminalSession({
- environmentId,
- driver,
- providerInstanceId: provider.instanceId,
- cwd: serverConfig.cwd,
- command: provider.installed
- ? resolveOnboardingProviderLoginCommand(
- provider,
- serverConfig.settings,
- serverConfig.environment.platform.os,
- )
- : resolveOnboardingProviderInstallCommand(
- driver,
- serverConfig.environment.platform.os,
- ),
- keybindings: serverConfig.keybindings,
- });
- }}
- />
- ),
- )}
-
- {chatGptAllowed &&
- providers?.some(
- (provider) =>
- provider.driver === "codex" && getOnboardingProviderState(provider) === "ready",
- ) ? (
-
- setAddingAccount(true)}>
- Connect another ChatGPT account
-
-
- ) : null}
- {addingAccount ? (
- setAddingAccount(false)}
- onAccountCreated={(instanceId, displayName) =>
- setCreatedAccount({ instanceId, displayName, autoStart: true })
- }
- />
- ) : null}
- {terminalSession !== null ? (
- {
- setTerminalSession(null);
- void refreshProviders({ environmentId, input: {} });
- }}
- />
- ) : null}
-
- );
-}
-
-function OnboardingCodexSetup({
- createdAccount,
- onAutoStartConsumed,
- environmentId,
- provider,
- serverConfig,
- terminalOpen,
- onOpenTerminal,
-}: {
- readonly environmentId: EnvironmentId;
- readonly provider: ServerProvider | undefined;
- readonly serverConfig: ServerConfig;
- readonly terminalOpen: boolean;
- readonly onOpenTerminal: () => void;
- readonly createdAccount: {
- instanceId: ProviderInstanceId;
- displayName: string;
- autoStart: boolean;
- } | null;
- readonly onAutoStartConsumed: () => void;
-}) {
- const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
- const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");
- const instanceId =
- createdAccount?.instanceId ??
- provider?.instanceId ??
- defaultInstanceIdForDriver(ProviderDriverKind.make("codex"));
- const settings = serverConfig.settings;
- const instance = settings.providerInstances[instanceId] ?? {
- driver: ProviderDriverKind.make("codex"),
- config: createdAccount ? { enabled: true, setupMode: "managed" } : {},
- };
- const mode = readCodexSetupMode(instance.config);
- const existingChosen =
- mode === "existing" &&
- instance.config !== null &&
- typeof instance.config === "object" &&
- "setupMode" in instance.config &&
- instance.config.setupMode === "existing";
- const changeMode = (setupMode: "managed" | "existing") => {
- void update({
- environmentId,
- input: {
- patch: buildProviderInstanceUpdatePatch({
- settings,
- instanceId,
- instance: {
- ...instance,
- enabled: true,
- config: {
- ...(instance.config !== null && typeof instance.config === "object"
- ? instance.config
- : {}),
- enabled: true,
- setupMode,
- },
- },
- }),
- },
- });
- };
- return existingChosen ? (
-
- ) : (
-
- );
-}
-
-function AgentCard({
- driver,
- provider,
- terminalOpen,
- terminalAvailable,
- onOpenTerminal,
-}: {
- readonly driver: OnboardingAgentDriver;
- readonly provider: ServerProvider | undefined;
- readonly terminalOpen: boolean;
- readonly terminalAvailable: boolean;
- readonly onOpenTerminal: () => void;
-}) {
- const meta = providerClients.get(ProviderDriverKind.make(driver));
- const displayName =
- provider?.displayName || (driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver));
- const summary = getProviderSummary(provider);
- const providerState = getOnboardingProviderState(provider);
-
- return (
-
-
-
-
{displayName}
-
- {providerState === "ready" ? "Ready to code." : summary.headline}
- {providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}
-
-
-
- {providerState === "ready" ? (
-
-
- Ready
-
- ) : providerState === "checking" ? (
- Checking...
- ) : providerState === "disabled" ? (
- Disabled
- ) : providerState === "attention" ? (
- {summary.headline}
- ) : (
-
-
- {providerState === "signIn" ? "Sign in" : "Install"}
-
- )}
-
-
- );
-}
-
-function AgentInstallTerminal({
- session,
- onClose,
-}: {
- readonly session: AgentTerminalSession;
- readonly onClose: () => void;
-}) {
- const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;
- const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
- const [advancedTypography] = useLocalStorage(
- TYPOGRAPHY_ADVANCED_STORAGE_KEY,
- false,
- Schema.Boolean,
- );
- const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false });
- const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false });
- const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
- const setupQueueRef = useRef(Promise.resolve());
- const setupGenerationRef = useRef(0);
- const activeSetupGenerationRef = useRef(null);
- const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`);
- const threadRef = scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID);
- const [setupAttempt, setSetupAttempt] = useState(0);
- const [setupState, setSetupState] = useState<
- "preparing" | "ready" | "openFailed" | "writeFailed"
- >("preparing");
- const terminalReady = setupState === "ready" || setupState === "writeFailed";
-
- // StrictMode can finish a cancelled open after replacement setup; it must not close or pre-type into that
- // session.
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the setup PTY, types the command into it, and closes it on cleanup
- useEffect(() => {
- const generation = setupGenerationRef.current + 1;
- setupGenerationRef.current = generation;
- activeSetupGenerationRef.current = generation;
- // oxlint-disable-next-line react/set-state-in-effect -- A retry or a new command reopens the terminal, which shows as preparing until the open settles.
- setSetupState("preparing");
-
- setupQueueRef.current = setupQueueRef.current.then(async () => {
- if (activeSetupGenerationRef.current !== generation) return;
- if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
- setSetupState("openFailed");
- return;
- }
- const opened = await openTerminal({
- environmentId,
- input: {
- threadId: AGENT_ONBOARDING_THREAD_ID,
- terminalId,
- cwd,
- providerInstanceId,
- },
- });
- if (opened._tag !== "Success") {
- if (activeSetupGenerationRef.current === generation) setSetupState("openFailed");
- return;
- }
-
- if (activeSetupGenerationRef.current !== generation) return;
- if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
- setSetupState("writeFailed");
- return;
- }
-
- const wrote = await writeTerminal({
- environmentId,
- input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command },
- });
- if (activeSetupGenerationRef.current !== generation) return;
- setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");
- });
-
- // Revocation leaves the PTY running; close only while terminal access remains.
- return () => {
- if (activeSetupGenerationRef.current === generation) {
- activeSetupGenerationRef.current = null;
- }
- setupQueueRef.current = setupQueueRef.current.then(async () => {
- if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) return;
- await closeTerminal({
- environmentId,
- input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },
- });
- });
- };
- }, [
- closeTerminal,
- command,
- cwd,
- environmentId,
- openTerminal,
- providerInstanceId,
- // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry bumps setupAttempt to open the terminal again.
- setupAttempt,
- terminalId,
- writeTerminal,
- ]);
-
- return (
-
-
-
- {!canOperateTerminal ? (
- "This connection cannot control terminals."
- ) : setupState === "writeFailed" ? (
- <>
- Run {command} in this
- terminal.
- >
- ) : setupState === "ready" ? (
- "Review the command, then press Enter to run it."
- ) : setupState === "openFailed" ? (
- "Could not open the setup terminal."
- ) : (
- "Preparing command..."
- )}
-
-
- {setupState === "openFailed" ? (
- {
- if (readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
- setSetupAttempt((value) => value + 1);
- }
- }}
- >
- Retry
-
- ) : null}
-
- Close
-
-
-
-
- {terminalReady ? (
-
- ) : null}
-
-
- );
-}
-
-const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history.";
-
-interface ImportSelection {
- // Computers whose projects were listed when the selection changed; later ones get the defaults.
- readonly environmentIds: ReadonlySet;
- readonly keys: ReadonlySet;
-}
-
-function ImportStep({
- scans,
- importSelection,
- onImportSelectionChange,
- isImporting,
- setIsImporting,
- onDone,
-}: {
- readonly scans: ReturnType;
- readonly importSelection: ImportSelection | null;
- readonly onImportSelectionChange: (next: ImportSelection) => void;
- readonly isImporting: boolean;
- readonly setIsImporting: (value: boolean) => void;
- readonly onDone: (
- projectRef?: ScopedProjectRef,
- importWarning?: string,
- importedThreadCount?: number,
- ) => Promise;
-}) {
- const { environments } = useEnvironments();
- const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope);
- const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
- const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
- const importWarningRef = useRef("");
- const importedThreadCountRef = useRef(0);
- const importedProjectsRef = useRef(new Map());
- const projectsWithImportedHistoryRef = useRef(new Map());
- const lastImportSelectionRef = useRef>([]);
- const projectAttemptsRef = useRef(
- new Map(),
- );
- const importGenerationRef = useRef(0);
-
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- bumps the import generation on mount and unmount so in-flight imports drop stale results
- useEffect(() => {
- importGenerationRef.current += 1;
- return () => {
- importGenerationRef.current += 1;
- };
- }, []);
-
- const { available: candidates, recent } = partitionOnboardingProjects(
- scans.flatMap((scan) =>
- (scan.data?.candidates ?? []).map((candidate) => ({
- ...candidate,
- environmentId: scan.environmentId,
- key: onboardingProjectKey(scan.environmentId, candidate.path),
- })),
- ),
- );
- const recentKeys = new Set(recent.map((candidate) => candidate.key));
- const selected = candidates.filter((candidate) =>
- importSelection?.environmentIds.has(candidate.environmentId)
- ? importSelection.keys.has(candidate.key)
- : recentKeys.has(candidate.key),
- );
- const selectedKeys = new Set(selected.map((candidate) => candidate.key));
- const setSelectedKeys = (keys: ReadonlySet) =>
- onImportSelectionChange({
- environmentIds: new Set(
- scans.flatMap((scan) => (scan.data === null ? [] : [scan.environmentId])),
- ),
- keys,
- });
-
- const canImport = selected.every((candidate) =>
- writableEnvironments.has(candidate.environmentId),
- );
- const [importError, setImportError] = useState("");
- const visibleImportError = !canImport
- ? IMPORT_PERMISSION_MESSAGE
- : importError === IMPORT_PERMISSION_MESSAGE
- ? ""
- : importError;
-
- const finishAfterImport = () => {
- const projectRef = resolveOnboardingLandingProject(
- lastImportSelectionRef.current,
- projectsWithImportedHistoryRef.current,
- importedProjectsRef.current,
- );
- if (projectRef === undefined) {
- void onDone(undefined, importWarningRef.current, importedThreadCountRef.current);
- return;
- }
- setIsImporting(true);
- const importGeneration = importGenerationRef.current;
- // waitForProject gives up after its timeout, so a project that never reaches the client
- // still finishes setup, just without landing in it.
- void waitForProject(projectRef)
- .then(
- () => projectRef,
- () => undefined,
- )
- .then(async (landing) => {
- if (importGeneration !== importGenerationRef.current) return;
- const completed = await onDone(
- landing,
- importWarningRef.current,
- importedThreadCountRef.current,
- );
- if (!completed) setIsImporting(false);
- });
- };
-
- const runImport = async (selection: typeof candidates) => {
- if (isImporting) return;
- const hasAccess = () =>
- selection.every((candidate) =>
- readEnvironmentScope(candidate.environmentId, AuthOrchestrationOperateScope),
- );
- const stopForDeniedAccess = () => {
- setIsImporting(false);
- setImportError(IMPORT_PERMISSION_MESSAGE);
- };
- if (!hasAccess()) {
- stopForDeniedAccess();
- return;
- }
- if (selection.length === 0) {
- void onDone();
- return;
- }
- setIsImporting(true);
- importWarningRef.current = "";
- importedThreadCountRef.current = 0;
- lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
- const importGeneration = importGenerationRef.current;
- const importedProjects = importedProjectsRef.current;
- const projectAttempts = projectAttemptsRef.current;
- let importedProjectsCount =
- importedProjects.size > 0
- ? selection.filter((candidate) => importedProjects.has(candidate.key)).length
- : 0;
- let importedThreadCount = 0;
- let skippedThreadCount = 0;
- const refreshEnvironments = new Set();
- for (const candidate of selection) {
- const { environmentId } = candidate;
- if (
- importGeneration !== importGenerationRef.current ||
- importedProjects !== importedProjectsRef.current
- ) {
- return;
- }
- if (!hasAccess()) {
- stopForDeniedAccess();
- return;
- }
- if (importedProjects.has(candidate.key)) continue;
- let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
- if (projectId === null) {
- let attempt = projectAttempts.get(candidate.key);
- if (attempt === undefined) {
- const nextProjectId = newProjectId();
- attempt = {
- projectId: nextProjectId,
- commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),
- };
- projectAttempts.set(candidate.key, attempt);
- }
- projectId = attempt.projectId;
- const result = await createProject({
- environmentId,
- input: {
- projectId,
- commandId: attempt.commandId,
- title: candidate.title,
- workspaceRoot: candidate.path,
- createWorkspaceRootIfMissing: false,
- defaultModelSelection: null,
- },
- });
- if (
- importGeneration !== importGenerationRef.current ||
- importedProjects !== importedProjectsRef.current
- ) {
- return;
- }
- if (result._tag !== "Success") {
- if (!isAtomCommandInterrupted(result)) {
- projectAttempts.delete(candidate.key);
- refreshEnvironments.add(environmentId);
- }
- continue;
- }
- }
-
- if (!hasAccess()) {
- stopForDeniedAccess();
- return;
- }
- const threadImportResult = await importThreads({
- environmentId,
- input: { projectId, expectedWorkspaceRoot: candidate.path },
- });
- if (
- importGeneration !== importGenerationRef.current ||
- importedProjects !== importedProjectsRef.current
- ) {
- return;
- }
- if (threadImportResult._tag === "Success") {
- importedThreadCount += threadImportResult.value.importedCount;
- skippedThreadCount += threadImportResult.value.skippedCount;
- if (threadImportResult.value.importedCount > 0) {
- projectsWithImportedHistoryRef.current.set(
- candidate.key,
- scopeProjectRef(environmentId, projectId),
- );
- }
- if (threadImportResult.value.skippedCount === 0) {
- importedProjectsCount += 1;
- importedProjects.set(candidate.key, scopeProjectRef(environmentId, projectId));
- }
- } else if (!isAtomCommandInterrupted(threadImportResult)) {
- projectAttempts.delete(candidate.key);
- refreshEnvironments.add(environmentId);
- }
- }
- for (const scan of scans) {
- if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
- }
- setIsImporting(false);
- importedThreadCountRef.current = importedThreadCount;
- if (importedProjectsCount < selection.length) {
- if (importedThreadCount > 0 && skippedThreadCount > 0) {
- importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`;
- } else if (skippedThreadCount > 0) {
- importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`;
- } else if (importedThreadCount > 0) {
- importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`;
- } else {
- importWarningRef.current = "Could not import thread history.";
- }
- }
- finishAfterImport();
- };
-
- if (scans.every((scan) => scan.data === null) && scans.some((scan) => scan.isPending)) {
- return (
-
-
Your projects
-
-
-
- Looking for projects from Claude Code and Codex…
-
-
-
- void onDone()}>
- Do not import projects
-
-
-
- );
- }
-
- return (
-
- {candidates.length > 0 ? (
-
-
- {selected.length} of {candidates.length} selected
-
-
- setSelectedKeys(new Set(candidates.map((item) => item.key)))}
- >
- Select all
-
- setSelectedKeys(new Set())}
- >
- Select none
-
-
-
- ) : null}
-
-
- {scans.map((scan) => {
- const scanCandidates = candidates.filter(
- (candidate) => candidate.environmentId === scan.environmentId,
- );
- const label =
- environments.find((environment) => environment.environmentId === scan.environmentId)
- ?.label ?? "Computer";
- return (
-
- {scans.length > 1 ? (
- {label}
- ) : null}
- {scan.isPending && scan.data === null ? (
-
-
- Looking for projects…
-
- ) : scan.error !== null ? (
-
- Could not check projects. {scan.error}
-
- Retry
-
-
- ) : scanCandidates.length === 0 ? (
-
- No existing Claude Code or Codex projects found.
-
- ) : null}
- {scan.data?.truncated ? (
-
- {SCAN_LIMIT_MESSAGE}
-
- ) : null}
-
-
- );
- })}
-
-
- {visibleImportError ? (
- {visibleImportError}
- ) : null}
-
-
- Do not import projects
-
- void runImport(selected)}
- >
- {isImporting
- ? "Importing…"
- : `Import ${selected.length} ${selected.length === 1 ? "project" : "projects"}`}
-
-
-
- );
-}
-
-type ImportCandidate = AgentSessionProjectCandidate & {
- readonly environmentId: EnvironmentId;
- readonly key: string;
-};
-
-function ImportCandidateList({
- candidates,
- selectedKeys,
- onSelectionChange,
-}: {
- readonly candidates: ReadonlyArray;
- readonly selectedKeys: ReadonlySet;
- readonly onSelectionChange: (next: ReadonlySet) => void;
-}) {
- const { repositories, other } = groupOnboardingProjects(candidates);
- const setKeys = (keys: ReadonlyArray, checked: boolean) => {
- const next = new Set(selectedKeys);
- for (const key of keys) {
- if (checked) next.add(key);
- else next.delete(key);
- }
- onSelectionChange(next);
- };
- const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length;
-
- return (
- <>
- {repositories.map((group) => (
-
- ))}
- {other.length > 0 ? (
-
-
- 0 && otherSelected < other.length}
- onCheckedChange={(checked) =>
- setKeys(
- other.map((candidate) => candidate.key),
- checked === true,
- )
- }
- />
-
-
- Other folders
-
- {other.length} {other.length === 1 ? "folder" : "folders"}
-
-
-
-
- {other.map((candidate) => (
- setKeys([candidate.key], checked)}
- />
- ))}
-
-
- ) : null}
- >
- );
-}
-
-function ImportRepositoryGroup({
- group,
- selectedKeys,
- onToggle,
-}: {
- readonly group: OnboardingProjectGroup;
- readonly selectedKeys: ReadonlySet;
- readonly onToggle: (keys: ReadonlyArray, checked: boolean) => void;
-}) {
- const keys = group.candidates.map((candidate) => candidate.key);
- const selectedCount = keys.filter((key) => selectedKeys.has(key)).length;
- const single = group.candidates.length === 1;
- const only = group.candidates[0];
- if (single && only !== undefined) {
- return (
- onToggle([only.key], checked)}
- />
- );
- }
- return (
-
-
- 0 && selectedCount < keys.length}
- onCheckedChange={(checked) => onToggle(keys, checked === true)}
- />
-
-
- {group.label}
- c.sources))]}
- threadCount={group.threadCount}
- lastActiveAt={group.lastActiveAt}
- />
-
-
-
- {group.candidates.map((candidate) => (
- onToggle([candidate.key], checked)}
- />
- ))}
-
-
- );
-}
-
-function ProjectPathText({
- path,
- className,
-}: {
- readonly path: string;
- readonly className?: string;
-}) {
- const { name, parent } = splitOnboardingProjectPath(path);
- return (
-
- {parent.length > 0 ? {parent} : null}
- {name}
-
- );
-}
-
-function ImportCandidateRow({
- candidate,
- label,
- path,
- nested = false,
- checked,
- onCheckedChange,
-}: {
- readonly candidate: ImportCandidate;
- readonly label?: string;
- readonly path?: string;
- readonly nested?: boolean;
- readonly checked: boolean;
- readonly onCheckedChange: (checked: boolean) => void;
-}) {
- return (
-
- onCheckedChange(value === true)} />
-
- }
- >
- {label !== undefined ? (
- {label}
- ) : null}
- {path !== undefined ? (
-
- ) : null}
-
- {candidate.path}
-
-
-
- );
-}
-
-const IMPORT_SOURCE_NAMES: Record = {
- claudeAgent: "Claude Code",
- codex: "Codex",
- pi: "Pi",
-};
-
-function ImportRowMeta({
- sources,
- threadCount,
- lastActiveAt,
-}: {
- readonly sources: ReadonlyArray | null;
- readonly threadCount: number;
- readonly lastActiveAt: string | null;
-}) {
- const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt);
- const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value;
- return (
-
- {AgentSessionSource.literals.map((source) => (
-
- {sources?.includes(source) ? (
-
-
-
- ) : null}
-
- ))}
- {threadCount}
- {age}
-
- );
-}
-
-/** The desktop app links `t3` on a fresh install; this puts its folder on PATH when it is not. */
-function CliPathSetup() {
- const { state, busy, run } = useDesktopCliCommand();
- const setup = state?.pathSetup;
- if (!setup) return null;
- return (
-
-
- {setup.added ? (
- <>
- Added to {setup.file}. Open a new terminal to use t3.
- >
- ) : (
- <>
- Use t3 from any terminal. Adds one line to{" "}
- {setup.file}.
- >
- )}
-
- {setup.added ? null : (
-
run("addToPath")}>
- Add to PATH
-
- )}
-
- );
-}
-
-function StepShell({
- title,
- description,
- children,
-}: {
- readonly title: string;
- readonly description?: string;
- readonly children?: React.ReactNode;
-}) {
- return (
- <>
- {title}
- {description ? (
- {description}
- ) : null}
- {children}
- >
- );
-}
diff --git a/apps/web/src/components/settings/AddCodexAccountDialog.browser.test.tsx b/apps/web/src/components/settings/AddCodexAccountDialog.browser.test.tsx
index 8204299ba3d84..c7c223c656e37 100644
--- a/apps/web/src/components/settings/AddCodexAccountDialog.browser.test.tsx
+++ b/apps/web/src/components/settings/AddCodexAccountDialog.browser.test.tsx
@@ -74,7 +74,13 @@ function createdInstanceId(): ProviderInstanceId {
return ProviderInstanceId.make(Object.keys(call[0].input.patch.providerInstances)[0]!);
}
-function Owner({ onClose }: { readonly onClose: () => void }) {
+function Owner({
+ onClose,
+ onAccountCreated,
+}: {
+ readonly onClose: () => void;
+ readonly onAccountCreated: (instanceId: ProviderInstanceId) => void;
+}) {
const [shown, setShown] = useState(true);
return shown ? (
void }) {
onClose();
setShown(false);
}}
+ onAccountCreated={onAccountCreated}
renderSetup={() => Sign in to ChatGPT
}
/>
) : null;
@@ -93,6 +100,7 @@ describe("AddCodexAccountDialog", () => {
const atom = Atom.make | null>([]).pipe(Atom.keepAlive);
providersAtom.current = atom;
const onClose = vi.fn();
+ const onAccountCreated = vi.fn();
// A long exit transition that the test finishes by hand, so the close is
// observable mid-animation without waiting on real time.
const style = document.createElement("style");
@@ -102,13 +110,14 @@ describe("AddCodexAccountDialog", () => {
using _style = { [Symbol.dispose]: () => style.remove() };
await using _screen = await render(
-
+
,
);
await userEvent.click(page.getByRole("button", { name: "Continue" }));
const instanceId = createdInstanceId();
await act(() => registry.set(atom, [codexProvider(instanceId, { status: "unauthenticated" })]));
+ // Telling the owner about the account does not skip the dialog's own sign-in.
await expect.element(page.getByText("Sign in to ChatGPT")).toBeVisible();
await act(() =>
diff --git a/apps/web/src/components/settings/AddCodexAccountDialog.tsx b/apps/web/src/components/settings/AddCodexAccountDialog.tsx
index a95bb6dfb5e85..bc43009864c31 100644
--- a/apps/web/src/components/settings/AddCodexAccountDialog.tsx
+++ b/apps/web/src/components/settings/AddCodexAccountDialog.tsx
@@ -31,6 +31,7 @@ export function AddCodexAccountDialog({
}: {
readonly environmentId: EnvironmentId;
readonly onClose: () => void;
+ // Told when the account is created; the dialog then runs its own sign-in.
readonly onAccountCreated?:
| ((instanceId: ProviderInstanceId, displayName: string) => void)
| undefined;
@@ -71,12 +72,8 @@ export function AddCodexAccountDialog({
},
});
if (result._tag === "Success") {
- if (onAccountCreated) {
- onAccountCreated(id, displayName);
- onClose();
- } else {
- setInstanceId(id);
- }
+ onAccountCreated?.(id, displayName);
+ setInstanceId(id);
}
setPending(false);
};
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
index 9b1829879405f..e5074e4a9bfd1 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
@@ -140,7 +140,9 @@ interface AddProviderInstanceDialogProps {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly onOpenChange: (open: boolean) => void;
- readonly onCreated?: (instanceId: ProviderInstanceId) => void;
+ readonly onCreated?: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => void;
+ /** Opens with "Show more" expanded, so the ACP Registry search is in view. */
+ readonly showMoreProviders?: boolean;
}
export function AddProviderInstanceDialog({
@@ -149,6 +151,7 @@ export function AddProviderInstanceDialog({
environmentLabel,
onOpenChange,
onCreated,
+ showMoreProviders: initialShowMoreProviders = false,
}: AddProviderInstanceDialogProps) {
const settings = useEnvironmentSettings(environmentId);
const secretSources = useSecretSources(environmentId);
@@ -179,7 +182,7 @@ export function AddProviderInstanceDialog({
ReadonlyArray
>([]);
const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false);
- const [showMoreProviders, setShowMoreProviders] = useState(false);
+ const [showMoreProviders, setShowMoreProviders] = useState(initialShowMoreProviders);
const [configByDriver, setConfigByDriver] = useState>>({});
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -405,7 +408,7 @@ export function AddProviderInstanceDialog({
});
return;
}
- onCreated?.(brandedId);
+ onCreated?.(brandedId, driver);
if (isAcpRegistry && !isLocalAcp) {
setCreatedInstanceId(brandedId);
setIsSaving(false);
@@ -425,6 +428,7 @@ export function AddProviderInstanceDialog({
onOpenChange(false)}
+ onAccountCreated={(instanceId) => onCreated?.(instanceId, ProviderDriverKind.make("codex"))}
/>
);
}
diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx
index f4dd3da002865..dea5f40dfea33 100644
--- a/apps/web/src/components/settings/CodexSetupSection.tsx
+++ b/apps/web/src/components/settings/CodexSetupSection.tsx
@@ -95,6 +95,8 @@ interface CodexSetupSectionProps {
readonly mode: "managed" | "existing";
readonly enabled: boolean;
readonly readOnly?: boolean;
+ // Onboarding's "Use existing CLI" opens a terminal, which this session may not be allowed.
+ readonly existingCliDisabled?: boolean;
readonly presentation?: "settings" | "onboarding";
readonly onModeChange: (mode: "managed" | "existing") => void;
readonly autoStart?: boolean;
@@ -172,7 +174,12 @@ export function CodexSetupSection(props: CodexSetupSectionProps) {
}
secondaryControl={
!existingAuthenticated && !existingReady && !existingChecking ? (
- props.onModeChange("existing")}>
+ props.onModeChange("existing")}
+ >
Use existing CLI
) : null
@@ -258,6 +265,7 @@ function ManagedCodexSetup({
autoStart,
onAutoStartConsumed,
allowExistingCli = true,
+ existingCliDisabled,
presentation,
displayName,
onSignInCancelled,
@@ -909,7 +917,7 @@ function ManagedCodexSetup({
onModeChange("existing")}
>
Use existing CLI
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 8df97a2ba0aaa..00bdcf6cf57b6 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -72,7 +72,11 @@ import {
import { useTextGenerationTargets } from "../../textGenerators";
import { ensureLocalApi, readLocalApi } from "../../localApi";
import { isMacPlatform } from "../../lib/utils";
-import { useEnvironmentProviders, useEnvironments } from "../../state/environments";
+import {
+ useEnvironmentProviders,
+ useEnvironments,
+ usePrimaryEnvironmentId,
+} from "../../state/environments";
import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
import { Button } from "../ui/button";
import { DraftInput } from "../ui/draft-input";
@@ -2050,6 +2054,8 @@ export function MaintenanceSettingsPanel() {
export function AboutSettingsPanel() {
const primaryPolicy = usePrimaryManagedPolicy();
+ // The Welcome page sets up this computer, so it needs one.
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
const telemetryOff = managedTelemetryOffMessage(primaryPolicy);
return (
@@ -2085,6 +2091,17 @@ export function AboutSettingsPanel() {
>
)}
+ {primaryEnvironmentId === null ? null : (
+ }>
+ Open
+
+ }
+ />
+ )}
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index 6f62a0fa78693..c360265f81e31 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -168,6 +168,25 @@ describe("searchSettings", () => {
);
});
+ it("finds the Welcome page only where a primary computer can open it", () => {
+ const availability = {
+ hasCloudPublicConfig: true,
+ hasEnvironment: true,
+ hasProviderSettingsEnvironment: true,
+ canManageLocalBackend: false,
+ isWslSettingsRowVisible: false,
+ hasThreadAutoSettlement: false,
+ };
+ const ids = (hasPrimaryEnvironment: boolean) =>
+ searchSettings(
+ "welcome page",
+ filterAvailableSettingsSearchItems({ ...availability, hasPrimaryEnvironment }),
+ ).map((item) => item.id);
+ // Hosted web reaches remote computers but has no primary one to set up.
+ expect(ids(false)).not.toContain("welcome-page");
+ expect(ids(true)).toContain("welcome-page");
+ });
+
it("finds keybinding commands by label, command id, and default key", () => {
expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle");
expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle");
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 768ce76c2238b..7e0c2a9cc73d6 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -65,6 +65,7 @@ export interface SettingsSearchItem {
readonly providerSettingsOnly?: boolean;
readonly localBackendManagementOnly?: boolean;
readonly localEnvironmentOnly?: boolean;
+ readonly primaryEnvironmentOnly?: boolean;
readonly wslAvailableOnly?: boolean;
readonly managedTunnelOnly?: boolean;
readonly peerLinksOnly?: boolean;
@@ -87,6 +88,7 @@ export interface SettingsSearchAvailability {
readonly hasFastWorktreeCheckoutEnvironment?: boolean;
/** An environment without a provider allow-list, which hides text generators. */
readonly hasTextGeneratorsEnvironment?: boolean;
+ readonly hasPrimaryEnvironment?: boolean;
}
export const SETTINGS_SECTION_LABELS: Readonly> = {
@@ -856,6 +858,13 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/about",
searchTerms: ["phone tablet iPhone iPad iOS Android App Store Google Play QR"],
},
+ {
+ id: "welcome-page",
+ title: "Welcome page",
+ to: "/settings/about",
+ primaryEnvironmentOnly: true,
+ searchTerms: ["onboarding setup first run getting started agents import projects"],
+ },
{
id: "privacy-policy",
title: "Privacy policy",
@@ -1460,6 +1469,7 @@ export function filterAvailableSettingsSearchItems(
(!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
(!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
(!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
+ (!item.primaryEnvironmentOnly || availability.hasPrimaryEnvironment === true) &&
(!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
(!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement) &&
(!item.managedTunnelOnly || availability.managedTunnelActive === true) &&
diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts
index 912e6c7c3f5a4..8dc0bce3a75f0 100644
--- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts
+++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts
@@ -72,5 +72,6 @@ export function useAvailableSettingsSearchItems() {
hasPeerLinkHost,
hasFastWorktreeCheckoutEnvironment,
hasTextGeneratorsEnvironment,
+ hasPrimaryEnvironment: primaryEnvironmentId !== null,
});
}
diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts
index 36766215fc2aa..10cb0d731ee75 100644
--- a/apps/web/src/onboarding/projectImport.logic.test.ts
+++ b/apps/web/src/onboarding/projectImport.logic.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
groupOnboardingProjects,
+ isNewOnboardingProject,
partitionOnboardingProjects,
onboardingProjectKey,
resolveOnboardingLandingProject,
@@ -141,6 +142,7 @@ describe("resolveOnboardingProjectId", () => {
const localProjectId = ProjectId.make("local-project");
it("uses the scanned project ID when the client still has an older project at that root", () => {
+ const scanned = candidate("/projects/repo", { projectId: localProjectId });
expect(
resolveOnboardingProjectId(
[
@@ -151,46 +153,47 @@ describe("resolveOnboardingProjectId", () => {
},
],
localEnvironmentId,
- candidate("/projects/repo", { projectId: localProjectId }),
+ scanned,
),
).toBe(localProjectId);
+ expect(isNewOnboardingProject([])(localEnvironmentId, scanned)).toBe(false);
});
it("finds an existing project by normalized root in the target environment", () => {
+ const projects = [
+ {
+ id: ProjectId.make("remote-project"),
+ environmentId: remoteEnvironmentId,
+ workspaceRoot: "C:\\Work\\Repo",
+ },
+ {
+ id: localProjectId,
+ environmentId: localEnvironmentId,
+ workspaceRoot: "C:\\Work\\Repo\\",
+ },
+ ];
expect(
- resolveOnboardingProjectId(
- [
- {
- id: ProjectId.make("remote-project"),
- environmentId: remoteEnvironmentId,
- workspaceRoot: "C:\\Work\\Repo",
- },
- {
- id: localProjectId,
- environmentId: localEnvironmentId,
- workspaceRoot: "C:\\Work\\Repo\\",
- },
- ],
- localEnvironmentId,
- candidate("c:/work/repo"),
- ),
+ resolveOnboardingProjectId(projects, localEnvironmentId, candidate("c:/work/repo")),
).toBe(localProjectId);
+ expect(isNewOnboardingProject(projects)(localEnvironmentId, candidate("c:/work/repo"))).toBe(
+ false,
+ );
});
it("does not reuse a project from another environment", () => {
+ const projects = [
+ {
+ id: ProjectId.make("remote-project"),
+ environmentId: remoteEnvironmentId,
+ workspaceRoot: "/projects/repo",
+ },
+ ];
expect(
- resolveOnboardingProjectId(
- [
- {
- id: ProjectId.make("remote-project"),
- environmentId: remoteEnvironmentId,
- workspaceRoot: "/projects/repo",
- },
- ],
- localEnvironmentId,
- candidate("/projects/repo"),
- ),
+ resolveOnboardingProjectId(projects, localEnvironmentId, candidate("/projects/repo")),
).toBeNull();
+ expect(isNewOnboardingProject(projects)(localEnvironmentId, candidate("/projects/repo"))).toBe(
+ true,
+ );
});
});
diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts
index bb87d24ce6d56..1be9c7b824b2a 100644
--- a/apps/web/src/onboarding/projectImport.logic.ts
+++ b/apps/web/src/onboarding/projectImport.logic.ts
@@ -1,4 +1,7 @@
-import { findProjectByPath } from "@t3tools/client-runtime/state/projects";
+import {
+ findProjectByPath,
+ normalizeProjectPathForComparison,
+} from "@t3tools/client-runtime/state/projects";
import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";
const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
@@ -101,6 +104,32 @@ export function resolveOnboardingProjectId(
return null;
}
+/**
+ * Whether a candidate is not a project yet, by the same rules as `resolveOnboardingProjectId`.
+ * It reads the project list once, so a long candidate list costs one pass, not one per candidate.
+ */
+export function isNewOnboardingProject(
+ projects: ReadonlyArray<{
+ readonly environmentId: EnvironmentId;
+ readonly workspaceRoot: string;
+ }>,
+) {
+ const roots = new Set(
+ projects.map(
+ (project) =>
+ `${project.environmentId}\u0000${normalizeProjectPathForComparison(project.workspaceRoot)}`,
+ ),
+ );
+ return (
+ environmentId: EnvironmentId,
+ candidate: Pick,
+ ) => {
+ if (candidate.projectId !== undefined) return false;
+ const path = normalizeProjectPathForComparison(candidate.path);
+ return path.length === 0 || !roots.has(`${environmentId}\u0000${path}`);
+ };
+}
+
export function resolveOnboardingLandingProject(
selection: ReadonlyArray,
projectsWithImportedHistory: ReadonlyMap,
diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts
index 747b377c30228..1113768750588 100644
--- a/apps/web/src/onboarding/providerReadiness.logic.test.ts
+++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts
@@ -9,7 +9,6 @@ import { describe, expect, it } from "vite-plus/test";
import {
getOnboardingProviderState,
resolveOnboardingProviderLoginCommand,
- selectOnboardingProvidersByDriver,
} from "./providerReadiness.logic";
const readyCodex: ServerProvider = {
@@ -73,49 +72,6 @@ describe("getOnboardingProviderState", () => {
});
});
-describe("selectOnboardingProvidersByDriver", () => {
- it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => {
- const signedOutCodex: ServerProvider = {
- ...readyCodex,
- instanceId: ProviderInstanceId.make("codex_work"),
- status: "error",
- auth: { status: "unauthenticated" },
- };
-
- expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe(
- readyCodex,
- );
- });
-
- it("prefers a provider with an actionable sign-in over a failed provider", () => {
- const failedCodex: ServerProvider = { ...readyCodex, status: "error" };
- const signedOutCodex: ServerProvider = {
- ...readyCodex,
- instanceId: ProviderInstanceId.make("codex_work"),
- status: "error",
- auth: { status: "unauthenticated" },
- };
-
- expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe(
- signedOutCodex,
- );
- });
-
- it("prefers installed providers over missing or disabled instances", () => {
- const disabledCodex: ServerProvider = { ...readyCodex, enabled: false };
- const missingCodex: ServerProvider = {
- ...readyCodex,
- instanceId: ProviderInstanceId.make("codex_work"),
- installed: false,
- status: "error",
- };
-
- expect(
- selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"),
- ).toBe(readyCodex);
- });
-});
-
describe("resolveOnboardingProviderLoginCommand", () => {
it("uses the selected Codex account binary", () => {
const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") };
diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts
index 6fb427100bdf7..43b0102d8816f 100644
--- a/apps/web/src/onboarding/providerReadiness.logic.ts
+++ b/apps/web/src/onboarding/providerReadiness.logic.ts
@@ -44,34 +44,6 @@ export function getOnboardingProviderState(provider: ServerProvider | undefined)
return "attention";
}
-const PROVIDER_STATE_PRIORITY = {
- checking: 0,
- disabled: 1,
- install: 2,
- attention: 3,
- signIn: 4,
- ready: 5,
-} as const;
-
-export function selectOnboardingProvidersByDriver(
- providers: ReadonlyArray | null | undefined,
-) {
- const providersByDriver = new Map();
-
- for (const provider of providers ?? []) {
- const existing = providersByDriver.get(provider.driver);
- if (
- existing === undefined ||
- PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] >
- PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)]
- ) {
- providersByDriver.set(provider.driver, provider);
- }
- }
-
- return providersByDriver;
-}
-
// These need no Node/npm and install where provider maintenance recognizes native binaries.
const NATIVE_INSTALL_COMMANDS = {
claudeAgent: {
diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts
deleted file mode 100644
index 3a882ff92fb6d..0000000000000
--- a/apps/web/src/onboarding/targetEnvironment.logic.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { EnvironmentId } from "@t3tools/contracts";
-import { describe, expect, it } from "vite-plus/test";
-
-import { resolveOnboardingSetup } from "./targetEnvironment.logic";
-
-describe("resolveOnboardingSetup", () => {
- const computer = (id: string, phase: string) => ({
- environmentId: EnvironmentId.make(id),
- connection: { phase },
- });
- const local = computer("local", "connected");
-
- it("skips selected computers that will not connect on their own", () => {
- const environments = [
- local,
- computer("switched-off", "available"),
- computer("offline", "offline"),
- computer("failing", "reconnecting"),
- computer("old-client", "unsupported"),
- ];
- expect(
- resolveOnboardingSetup(
- environments,
- new Set(environments.map((environment) => environment.environmentId)),
- ),
- ).toEqual({
- ready: true,
- environmentIds: [local.environmentId],
- skippedIds: environments.slice(1).map((environment) => environment.environmentId),
- });
- });
-
- it("waits for a first connection attempt to settle", () => {
- const connecting = computer("new", "connecting");
- expect(
- resolveOnboardingSetup(
- [local, connecting],
- new Set([local.environmentId, connecting.environmentId]),
- ).ready,
- ).toBe(false);
- });
-
- // regression: #16887 onboarding Continue locked up on computers that won't connect
- it("is not ready without a connected selection", () => {
- expect(resolveOnboardingSetup([local], new Set()).ready).toBe(false);
- expect(
- resolveOnboardingSetup([computer("off", "available")], new Set([EnvironmentId.make("off")]))
- .ready,
- ).toBe(false);
- });
-});
diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts
deleted file mode 100644
index a217018dbb600..0000000000000
--- a/apps/web/src/onboarding/targetEnvironment.logic.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { type ConnectionCatalogEntry, hasRelayRoute } from "@t3tools/client-runtime/connection";
-import type { EnvironmentId } from "@t3tools/contracts";
-
-interface OnboardingEnvironment {
- readonly environmentId: EnvironmentId;
- readonly connection: { readonly phase: string };
- readonly entry: Pick;
-}
-
-export function isOnboardingRelayEnvironment(
- environment: Pick,
-): boolean {
- return hasRelayRoute(environment.entry);
-}
-
-/** Waits only for first connection attempts; offline, failing, or unsupported computers cannot block setup. */
-export function resolveOnboardingSetup(
- environments: ReadonlyArray>,
- selectedIds: ReadonlySet,
-): {
- readonly ready: boolean;
- readonly environmentIds: ReadonlyArray;
- readonly skippedIds: ReadonlyArray;
-} {
- const selected = environments.filter((environment) => selectedIds.has(environment.environmentId));
- const idsInPhase = (keep: (phase: string) => boolean) =>
- selected
- .filter((environment) => keep(environment.connection.phase))
- .map((environment) => environment.environmentId);
- const environmentIds = idsInPhase((phase) => phase === "connected");
- const settling = selected.some((environment) => environment.connection.phase === "connecting");
- return {
- ready: environmentIds.length > 0 && !settling,
- environmentIds,
- skippedIds: idsInPhase((phase) => phase !== "connected" && phase !== "connecting"),
- };
-}
diff --git a/apps/web/src/onboarding/welcomePage.logic.test.ts b/apps/web/src/onboarding/welcomePage.logic.test.ts
new file mode 100644
index 0000000000000..d2e8600692945
--- /dev/null
+++ b/apps/web/src/onboarding/welcomePage.logic.test.ts
@@ -0,0 +1,265 @@
+import {
+ type ManagedPolicySummary,
+ ProjectId,
+ resolveProviderInstanceEnabled,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ServerProvider,
+ type ServerSettings,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ cliTerminalStep,
+ codexCardProvider,
+ codexNeedsSetup,
+ groupWelcomeAgents,
+ turnedOnInstance,
+ welcomeOffersPhone,
+} from "./welcomePage.logic";
+
+function provider(name: string, overrides: Partial = {}): ServerProvider {
+ return {
+ instanceId: ProviderInstanceId.make(name.toLowerCase()),
+ driver: ProviderDriverKind.make(name.toLowerCase()),
+ displayName: name,
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-08-23T00:00:00.000Z",
+ models: [],
+ slashCommands: [],
+ skills: [],
+ ...overrides,
+ };
+}
+
+const nameOf = (entry: ServerProvider) => entry.displayName ?? entry.driver;
+const names = (entries: ReadonlyArray) => entries.map(nameOf);
+const allowAll = () => true;
+
+describe("groupWelcomeAgents", () => {
+ it("lists agents on the computer by state, then name, and everything else by name", () => {
+ const groups = groupWelcomeAgents(
+ [
+ provider("Zeta", { auth: { status: "unauthenticated" }, status: "error" }),
+ provider("Codex"),
+ provider("Pi", { enabled: false, status: "disabled" }),
+ provider("Grok", { installed: false, status: "error", auth: { status: "unknown" } }),
+ provider("Claude"),
+ provider("Cursor", { installed: false, status: "warning", auth: { status: "unknown" } }),
+ ],
+ undefined,
+ nameOf,
+ allowAll,
+ );
+
+ expect(names(groups.found)).toEqual(["Claude", "Codex", "Zeta"]);
+ // Cursor is still being checked; it stays listed instead of disappearing.
+ expect(names(groups.available)).toEqual(["Cursor", "Grok", "Pi"]);
+ expect(groups.checking).toBe(true);
+ expect(groups.readyCount).toBe(2);
+ });
+
+ it("uses this computer's settings and keeps agents that failed to load with the found ones", () => {
+ const acpRegistry = ProviderDriverKind.make("acpRegistry");
+ const settings: Pick = {
+ providerInstances: {
+ [ProviderInstanceId.make("pi")]: { driver: ProviderDriverKind.make("pi") },
+ [ProviderInstanceId.make("registry")]: { driver: acpRegistry },
+ },
+ projectSettingsOverrides: {
+ [ProjectId.make("project-a")]: {
+ providerInstances: { [ProviderInstanceId.make("pi")]: { enabled: true } },
+ },
+ },
+ };
+ const groups = groupWelcomeAgents(
+ [
+ // The server checks Pi because project A turns it on; new projects still cannot use it.
+ provider("Pi"),
+ provider("Broken", {
+ enabled: false,
+ installed: false,
+ status: "error",
+ availability: "unavailable",
+ unavailableReason: "Invalid config for instance 'broken'.",
+ }),
+ // Signed in and healthy, but the server knows this CLI version is broken.
+ provider("Codex", {
+ compatibilityAdvisory: {
+ status: "broken",
+ message: "Update Codex to 0.170 or later.",
+ recommendedVersion: "0.170.0",
+ recommendedRange: null,
+ },
+ }),
+ // An ACP agent that is not prepared looks like one still being checked.
+ provider("Registry", {
+ driver: acpRegistry,
+ installed: false,
+ status: "warning",
+ auth: { status: "unknown" },
+ }),
+ ],
+ settings,
+ nameOf,
+ allowAll,
+ );
+
+ expect(names(groups.found)).toEqual(["Broken", "Codex", "Registry"]);
+ expect(names(groups.available)).toEqual(["Pi"]);
+ expect(groups.readyCount).toBe(0);
+ expect(groups.checking).toBe(false);
+ });
+
+ it("leaves out agents the computer's policy does not allow, ready or not", () => {
+ const groups = groupWelcomeAgents(
+ [provider("Codex"), provider("Blocked")],
+ undefined,
+ nameOf,
+ (entry) => entry.driver !== "blocked",
+ );
+
+ expect(names(groups.found)).toEqual(["Codex"]);
+ expect(groups.readyCount).toBe(1);
+ });
+
+ it("reports checking before the computer has sent its providers", () => {
+ expect(groupWelcomeAgents(undefined, undefined, nameOf, allowAll)).toEqual({
+ checking: true,
+ readyCount: 0,
+ found: [],
+ available: [],
+ });
+ });
+});
+
+describe("cliTerminalStep", () => {
+ it("waits for the CLI's own check, then opens its terminal unless Codex is ready", () => {
+ const codex = (overrides: Partial = {}) => provider("Codex", overrides);
+ const step = (overrides?: Partial) =>
+ cliTerminalStep(codex(overrides), undefined);
+
+ // Still the ChatGPT sign-in runtime, before the switch to the CLI lands.
+ expect(
+ step({
+ setup: { canAuthenticate: true, canInstall: true },
+ auth: { status: "unauthenticated" },
+ }),
+ ).toBe("wait");
+ expect(step({ installed: false, status: "warning", auth: { status: "unknown" } })).toBe("wait");
+ expect(step({ installed: false, status: "error", auth: { status: "unknown" } })).toBe("open");
+ expect(step({ auth: { status: "unauthenticated" } })).toBe("open");
+ // A check that timed out still gets the sign-in command.
+ expect(step({ status: "error", auth: { status: "unknown" } })).toBe("open");
+ // A broken version is updated from its row in Settings, signed in, signed out, or unchecked.
+ const broken = {
+ status: "broken",
+ message: "Update Codex.",
+ recommendedVersion: null,
+ recommendedRange: null,
+ } as const;
+ expect(step({ compatibilityAdvisory: broken })).toBe("done");
+ expect(step({ compatibilityAdvisory: broken, auth: { status: "unauthenticated" } })).toBe(
+ "done",
+ );
+ expect(
+ step({ compatibilityAdvisory: broken, status: "error", auth: { status: "unknown" } }),
+ ).toBe("done");
+ // Signed in but needing a look for another reason: a sign-in would not help.
+ expect(step({ status: "warning" })).toBe("done");
+ // Signed in from its home files with the binary gone: the installer.
+ expect(step({ installed: false, status: "error" })).toBe("open");
+ expect(step()).toBe("done");
+ });
+});
+
+describe("turnedOnInstance", () => {
+ it("turns an agent on even when older settings keep it off inside its config", () => {
+ const pi = ProviderDriverKind.make("pi");
+ expect(resolveProviderInstanceEnabled(turnedOnInstance({ driver: pi }))).toBe(true);
+ const migrated = turnedOnInstance({ driver: pi, config: { enabled: false, binaryPath: "pi" } });
+ expect(resolveProviderInstanceEnabled(migrated)).toBe(true);
+ expect(migrated.config).toEqual({ enabled: true, binaryPath: "pi" });
+ });
+});
+
+describe("codexNeedsSetup", () => {
+ it("opens the setup card only while Codex is not signed in", () => {
+ const setup = (overrides: Partial) =>
+ codexNeedsSetup(provider("Codex", overrides), undefined);
+
+ expect(setup({ installed: false, status: "error", auth: { status: "unknown" } })).toBe(true);
+ expect(setup({ auth: { status: "unauthenticated" } })).toBe(true);
+ // A check that timed out can still sign in with ChatGPT.
+ expect(setup({ status: "error", auth: { status: "unknown" } })).toBe(true);
+ // Signed in from its home files but the binary is gone: Install, not the card.
+ expect(setup({ installed: false, status: "error" })).toBe(false);
+ // A broken version gets Update on its row.
+ expect(
+ setup({
+ auth: { status: "unauthenticated" },
+ compatibilityAdvisory: {
+ status: "broken",
+ message: null,
+ recommendedVersion: null,
+ recommendedRange: null,
+ },
+ }),
+ ).toBe(false);
+ expect(setup({})).toBe(false);
+ const claude = provider("Claude", { auth: { status: "unauthenticated" } });
+ expect(codexNeedsSetup(claude, undefined)).toBe(false);
+ });
+});
+
+describe("codexCardProvider", () => {
+ it("keeps the card on Codex's last status while a rebuild drops it, and closes once signed in", () => {
+ const signedOut = provider("Codex", { auth: { status: "unauthenticated" } });
+ const card = (providers: ReadonlyArray, last: ServerProvider | null = null) =>
+ codexCardProvider({ providers, instanceId: signedOut.instanceId, last, settings: undefined });
+
+ expect(card([signedOut])).toBe(signedOut);
+ // The instance is missing for one update while it is rebuilt.
+ expect(card([], signedOut)).toBe(signedOut);
+ expect(card([], provider("Other"))).toBeNull();
+ expect(card([provider("Codex")])).toBeNull();
+ expect(
+ codexCardProvider({
+ providers: [signedOut],
+ instanceId: null,
+ last: null,
+ settings: undefined,
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("welcomeOffersPhone", () => {
+ it("offers the phone only through T3 Connect on the primary computer, and only when allowed", () => {
+ const policy = (limits: ManagedPolicySummary["limits"]): ManagedPolicySummary => ({
+ organization: "Acme",
+ lockedSettings: [],
+ ...(limits === undefined ? {} : { limits }),
+ });
+ const offers = (input: Partial[0]>) =>
+ welcomeOffersPhone({
+ isPrimary: true,
+ cloud: true,
+ canShare: true,
+ policy: undefined,
+ ...input,
+ });
+
+ expect(offers({})).toBe(true);
+ // There is no pairing-code fallback; without T3 Connect the page leaves the phone out.
+ expect(offers({ cloud: false })).toBe(false);
+ expect(offers({ canShare: false })).toBe(false);
+ expect(offers({ policy: policy({ connect: false }) })).toBe(false);
+ expect(offers({ policy: policy({ clients: ["desktop"] }) })).toBe(false);
+ expect(offers({ isPrimary: false })).toBe(false);
+ });
+});
diff --git a/apps/web/src/onboarding/welcomePage.logic.ts b/apps/web/src/onboarding/welcomePage.logic.ts
new file mode 100644
index 0000000000000..acde259ad0d5d
--- /dev/null
+++ b/apps/web/src/onboarding/welcomePage.logic.ts
@@ -0,0 +1,180 @@
+import {
+ isProviderAvailable,
+ managedCapabilityDenial,
+ type ManagedPolicySummary,
+ type ProviderInstanceConfig,
+ ProviderDriverKind,
+ type ProviderInstanceId,
+ resolveProjectProviderInstanceEnabled,
+ type ServerProvider,
+ type ServerSettings,
+} from "@t3tools/contracts";
+
+import { getOnboardingProviderState } from "./providerReadiness.logic";
+
+type ComputerSettings = Pick;
+
+// Ready agents first, then ones that need a sign-in or a look. Never ranked by vendor.
+const FOUND_ORDER = { ready: 0, signIn: 1, attention: 2 } as const;
+type FoundState = keyof typeof FOUND_ORDER;
+
+const isFoundState = (state: string): state is FoundState => state in FOUND_ORDER;
+
+const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
+
+/**
+ * An agent's state for new threads on this computer. The server also lists agents that only some
+ * projects turn on, lists an agent it could not load as off, which Turn on cannot fix, and can pass
+ * a CLI whose version it knows is broken.
+ */
+export function welcomeAgentState(
+ provider: ServerProvider,
+ settings: ComputerSettings | null | undefined,
+) {
+ if (!isProviderAvailable(provider)) return "attention";
+ if (settings && !resolveProjectProviderInstanceEnabled(settings, null, provider.instanceId)) {
+ return "disabled";
+ }
+ const state = getOnboardingProviderState(provider);
+ if (state === "ready" && provider.compatibilityAdvisory?.status === "broken") return "attention";
+ // An ACP agent that is not prepared reports the same fields as one still being checked.
+ return state === "checking" && provider.driver === ACP_REGISTRY_DRIVER ? "attention" : state;
+}
+
+/**
+ * Splits a computer's allowed providers into agents already on it and agents the user can add.
+ * Order inside each group is by state, then by the name the user sees. Agents still being
+ * checked are listed with the ones to add, so a slow check never drops an agent.
+ */
+export function groupWelcomeAgents(
+ providers: ReadonlyArray | null | undefined,
+ settings: ComputerSettings | null | undefined,
+ nameOf: (provider: ServerProvider) => string,
+ isAllowed: (provider: ServerProvider) => boolean,
+) {
+ const found: Array<{ readonly provider: ServerProvider; readonly state: FoundState }> = [];
+ const available: ServerProvider[] = [];
+ let checking = providers == null;
+ let readyCount = 0;
+ for (const provider of providers ?? []) {
+ if (!isAllowed(provider)) continue;
+ const state = welcomeAgentState(provider, settings);
+ if (state === "ready") readyCount += 1;
+ if (state === "checking") checking = true;
+ if (isFoundState(state)) found.push({ provider, state });
+ else available.push(provider);
+ }
+ const byName = (a: ServerProvider, b: ServerProvider) => nameOf(a).localeCompare(nameOf(b));
+ found.sort(
+ (a, b) => FOUND_ORDER[a.state] - FOUND_ORDER[b.state] || byName(a.provider, b.provider),
+ );
+ available.sort(byName);
+ return { checking, readyCount, found: found.map((entry) => entry.provider), available };
+}
+
+/** Older settings can also keep `enabled: false` inside `config`, which still turns the agent off. */
+export function turnedOnInstance(instance: ProviderInstanceConfig): ProviderInstanceConfig {
+ const { config } = instance;
+ const configOff =
+ typeof config === "object" &&
+ config !== null &&
+ "enabled" in config &&
+ config.enabled === false;
+ return {
+ ...instance,
+ enabled: true,
+ ...(configOff ? { config: { ...config, enabled: true } } : {}),
+ };
+}
+
+/** A CLI the server knows is too old or otherwise broken for T3 Code; Settings updates it. */
+export function isBrokenVersion(provider: ServerProvider) {
+ return provider.installed && provider.compatibilityAdvisory?.status === "broken";
+}
+
+/**
+ * What "Use existing CLI" does next for Codex: wait for the CLI's own check, open its terminal,
+ * or stop. Only the ChatGPT sign-in runtime reports `setup.canAuthenticate`.
+ */
+export function cliTerminalStep(
+ provider: ServerProvider,
+ settings: ComputerSettings | null | undefined,
+) {
+ if (provider.setup?.canAuthenticate === true) return "wait";
+ const state = welcomeAgentState(provider, settings);
+ if (state === "checking") return "wait";
+ // A broken version is updated from its row, and an installed, signed-in CLI needs no terminal.
+ if (isBrokenVersion(provider)) return "done";
+ if (provider.installed && provider.auth.status === "authenticated") return "done";
+ // The terminal shows the command first, so it also helps a CLI whose check failed.
+ const fixable =
+ state === "install" ||
+ state === "signIn" ||
+ (state === "attention" && isProviderAvailable(provider));
+ return fixable ? "open" : "done";
+}
+
+/**
+ * Whether Codex's row opens its setup card, which offers ChatGPT sign-in or the CLI. A signed-in
+ * or broken Codex gets its plain row action instead, since the card has nothing left for it.
+ */
+export function codexNeedsSetup(
+ provider: ServerProvider,
+ settings: ComputerSettings | null | undefined,
+) {
+ if (
+ provider.driver !== "codex" ||
+ provider.auth.status === "authenticated" ||
+ isBrokenVersion(provider)
+ ) {
+ return false;
+ }
+ const state = welcomeAgentState(provider, settings);
+ return (
+ state === "install" ||
+ state === "signIn" ||
+ (state === "attention" && isProviderAvailable(provider))
+ );
+}
+
+/**
+ * The phone joins by signing in to T3 Connect, which shares the primary computer. Another
+ * computer, a build without T3 Connect, or a policy that turns it off gets no Phone section.
+ */
+export function welcomeOffersPhone(input: {
+ readonly isPrimary: boolean;
+ readonly cloud: boolean;
+ // Relay read and write access, which sharing this computer through T3 Connect needs.
+ readonly canShare: boolean;
+ readonly policy: ManagedPolicySummary | undefined;
+}) {
+ return (
+ input.isPrimary &&
+ input.cloud &&
+ input.canShare &&
+ managedCapabilityDenial(input.policy, "remoteClients") === undefined &&
+ managedCapabilityDenial(input.policy, "connect") === undefined
+ );
+}
+
+/**
+ * The Codex instance the setup card shows, or null when the card is closed. A rebuild briefly
+ * drops an instance from the list, so the card keeps its last status meanwhile instead of
+ * unmounting and losing a sign-in in progress.
+ */
+export function codexCardProvider(input: {
+ readonly providers: ReadonlyArray | null | undefined;
+ readonly instanceId: ProviderInstanceId | null;
+ readonly last: ServerProvider | null;
+ readonly settings: ComputerSettings | null | undefined;
+}) {
+ if (input.instanceId === null) return null;
+ const provider =
+ input.providers?.find((candidate) => candidate.instanceId === input.instanceId) ??
+ (input.last?.instanceId === input.instanceId ? input.last : undefined);
+ if (provider === undefined) return null;
+ const open =
+ codexNeedsSetup(provider, input.settings) ||
+ welcomeAgentState(provider, input.settings) === "checking";
+ return open ? provider : null;
+}
diff --git a/apps/web/src/onboarding/welcomeState.ts b/apps/web/src/onboarding/welcomeState.ts
new file mode 100644
index 0000000000000..e418cc361491c
--- /dev/null
+++ b/apps/web/src/onboarding/welcomeState.ts
@@ -0,0 +1,20 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+
+/**
+ * A computer's project choices on the Welcome page. They are stored until setup finishes, so a
+ * visit to Settings or an app restart during setup keeps them.
+ */
+export const WelcomeImportSelection = Schema.Struct({
+ // Projects listed when the selection changed; projects found later get the defaults.
+ knownKeys: Schema.Array(Schema.String),
+ keys: Schema.Array(Schema.String),
+ // Unchecking the main box also leaves out projects found later, until it is checked again.
+ optedOut: Schema.Boolean,
+});
+export type WelcomeImportSelection = typeof WelcomeImportSelection.Type;
+
+export const StoredWelcomeImportSelection = Schema.NullOr(WelcomeImportSelection);
+
+export const welcomeImportSelectionKey = (environmentId: EnvironmentId) =>
+ `t3code:welcome-import-selection:${environmentId}`;
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 804dc88e2d477..85a545c607d91 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -165,29 +165,38 @@ function RootRouteBranch() {
return hostedPromptLinkLanding;
}
- if (pathname === "/welcome") {
- return (
- <>
-
-
-
-
-
-
-
-
-
- >
- );
- }
-
+ const isWelcome = pathname === "/welcome";
if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
return ;
}
+ // Settings can reopen /welcome mid-session, so it mounts everything the app does except the
+ // first-run gate and the event router, whose first navigation would leave the page.
+ const appHosts = (
+ <>
+ {primaryEnvironmentAuthenticated ? : null}
+ {primaryEnvironmentAuthenticated || localEnvironmentDisabled ? (
+
+ ) : null}
+ {isElectron ? : null}
+
+
+
+
+
+
+
+
+
+
+
+ {import.meta.env.DEV && primaryEnvironmentAuthenticated ? : null}
+ >
+ );
const appShell = (
-
+ {/* Setup starts with an empty workspace, so the Welcome page has no thread list. */}
+
@@ -196,34 +205,25 @@ function RootRouteBranch() {
return (
<>
-
- {primaryEnvironmentAuthenticated ? : null}
- {primaryEnvironmentAuthenticated || localEnvironmentDisabled ? (
-
- ) : null}
- {isElectron ? : null}
-
-
-
-
-
-
-
-
-
-
-
- {import.meta.env.DEV && primaryEnvironmentAuthenticated ? : null}
- {primaryEnvironmentAuthenticated ? (
-
- ) : null}
- {appShell}
- {/* Keep the theme editor above the router so drafts survive navigation. */}
-
-
+ {isWelcome ? (
+ <>
+ {appHosts}
+ {appShell}
+ >
+ ) : (
+
+ {appHosts}
+ {primaryEnvironmentAuthenticated ? (
+
+ ) : null}
+ {appShell}
+
+ )}
+ {/* Above both shells and the router, so drafts survive navigation, including to /welcome. */}
+
>
);
}
diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx
index 5415f01e210f3..5fbb52359a1cf 100644
--- a/apps/web/src/routes/welcome.tsx
+++ b/apps/web/src/routes/welcome.tsx
@@ -5,8 +5,9 @@ import * as Schema from "effect/Schema";
import * as Option from "effect/Option";
import { NoProjectsHero } from "../components/NoProjectsHero";
-import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";
+import { WelcomePage } from "../components/onboarding/WelcomePage";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
+import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
const decodeEnvironmentId = Schema.decodeOption(EnvironmentId);
@@ -21,36 +22,41 @@ export const Route = createFileRoute("/welcome")({
});
function WelcomeRouteView() {
- const { authGateState } = Route.useRouteContext();
const navigate = useNavigate();
const hash = useLocation({ select: (location) => location.hash });
+ // A ChatGPT sign-in returns here with the computer it set up.
const resumeEnvironmentId = hash.startsWith("agents:")
? Option.getOrUndefined(decodeEnvironmentId(hash.slice("agents:".length)))
: undefined;
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const { environments, isReady } = useEnvironments();
+ // Trust the id only once the catalog has it; an unknown computer falls back to this one.
+ const waitingForCatalog =
+ !isReady && (resumeEnvironmentId !== undefined || primaryEnvironmentId === null);
+ const resumeKnown =
+ resumeEnvironmentId !== undefined &&
+ environments.some((environment) => environment.environmentId === resumeEnvironmentId);
+ const environmentId = (resumeKnown ? resumeEnvironmentId : undefined) ?? primaryEnvironmentId;
// The root can remount this pending outlet after navigation; do not reopen setup.
const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" });
const [dismissed, setDismissed] = useState(false);
const openNewThread = useNewThreadHandler();
- const localAvailable = authGateState.status === "authenticated";
+ if (waitingForCatalog) return null;
+ if (environmentId === null || !isWelcomeRoute || dismissed) return ;
return (
- <>
-
- {isWelcomeRoute && !dismissed ? (
- {
- setDismissed(true);
- if (projectRef !== undefined) {
- await openNewThread(projectRef, { replace: true }).catch(() =>
- navigate({ to: "/", replace: true }),
- );
- return;
- }
- await navigate({ to: "/", replace: true });
- }}
- />
- ) : null}
- >
+ {
+ setDismissed(true);
+ if (projectRef !== undefined) {
+ await openNewThread(projectRef, { replace: true }).catch(() =>
+ navigate({ to: "/", replace: true }),
+ );
+ return;
+ }
+ await navigate({ to: "/", replace: true });
+ }}
+ />
);
}
diff --git a/docs/user/install.md b/docs/user/install.md
index 48d96a4fef611..7b28781e69612 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -134,7 +134,7 @@ on first launch; after an update, or any time, choose **Install** next to
**t3 command** in **Settings → About**. On macOS and Linux it adds a `t3` link
in `~/.local/bin` (or `~/bin`); on Windows it adds the app's command folder to
your `PATH`. Open a new terminal afterwards. If the folder is not on your
-`PATH`, **Add to PATH** in the welcome setup or **Settings → About** adds one
+`PATH`, **Add to PATH** on the Welcome page or in **Settings → About** adds one
line to your shell's startup file. **Remove** takes it off, and the app does not
add it again. If you already have another `t3`, it stays as it is.
diff --git a/docs/user/welcome-page.md b/docs/user/welcome-page.md
new file mode 100644
index 0000000000000..f280ace079147
--- /dev/null
+++ b/docs/user/welcome-page.md
@@ -0,0 +1,111 @@
+# Welcome page
+
+T3 Code shows a Welcome page when you open a new installation. Existing
+workspaces skip it. It shows what T3 Code found on the computer you are using,
+on one page: your agents, projects from your Claude Code, Codex, and Pi history,
+and the phone app. Only an agent is needed to start chatting.
+
+To see the page again, go to **Settings → About → Welcome page** and select
+**Open**. Direct links, such as Settings, open normally before setup is done.
+
+The hosted app at app.t3.codes does not open the page. With no computer connected,
+it asks you to sign in to T3 Connect, then to install T3 Code and run
+`t3 connect` on your computer. From home, it adds compatible computers linked to your account and
+opens the app. To add more computers later, use **Settings → Environments**.
+
+If T3 Code cannot confirm the workspace during startup, it shows
+**Still connecting** instead of opening the app. Select **Reload** to try again.
+
+If T3 Code cannot read your saved settings, it shows **Could not read settings**.
+Select **Retry** after storage becomes available. Setup does not replace
+unreadable settings with defaults.
+
+## Agents
+
+T3 Code lists the agents it found on the computer first, ready ones on top.
+**Add more agents** lists every other agent T3 Code supports, A to Z. When no agent
+is found, it reads **Set up an agent** and opens by itself:
+
+- **Turn on** enables an agent that is off on this computer, including one only
+ some projects use. T3 Code then checks whether it is installed and signed in.
+- For Claude Code, **Install** or **Sign in** opens a terminal with the correct
+ command ready to run. Install uses the vendor's own installer, which keeps
+ **Update now** working in Settings.
+- For Codex, **Set up** offers sign-in with your ChatGPT account or your own
+ Codex CLI. **Use existing CLI** opens the terminal to install or sign in, and
+ **Continue with ChatGPT** stays available until Codex is signed in.
+- When T3 Code knows an installed version does not work with it, **Update** opens
+ the agent in **Settings → Providers**, which shows the version to use and updates it
+ when it can.
+- Other agents, and agents that could not load, open their page in
+ **Settings → Providers**.
+- **Browse** opens the ACP Registry for more agents.
+
+On the desktop app, when the folder of the `t3` command is not on your `PATH`,
+**Add to PATH** adds one line to your shell's startup file.
+
+The setup terminal uses the home directory and environment configured for the
+provider instance. Sensitive values remain redacted in Settings and
+terminal metadata while the terminal process can use them.
+
+**Start** waits for one ready agent. Select **Start without an agent** to finish
+without one. Checked projects are still imported.
+
+## Phone
+
+Select **Set up** and scan the code with your phone. It opens the App Store on an
+iPhone and Google Play on Android. Then select **Sign in to T3 Connect** and allow
+this computer when T3 Code asks, so the app can reach it. Sign in to the app with
+the same account. If you are signed in but this computer is not shared yet, select
+**Set up T3 Connect**. If T3 Connect is on but the app cannot reach this computer,
+select **Check T3 Connect** to see why. The page leaves the phone out on builds
+without T3 Connect, or when your organization turns it off. You can still pair the
+app with a code from **Settings → Sharing & access**.
+
+## Projects
+
+T3 Code finds directories that Claude Code, Codex or Pi has used. Git repositories
+are listed first, newest activity on top. When the remote is on GitHub, the
+group shows the repository as `owner/name`. Clones with the same remote share
+one group. Directories that are not git repositories sit under "Other folders".
+
+The page offers git repositories active within the last 30 days with at least
+three conversations, already checked. Projects already in T3 Code are not checked
+again. Uncheck the box to start without them, or
+select **Review all** to pick from everything found, with "Select all" and
+"Select none". Linked worktrees of bare repositories
+can be offered as separate projects. Other linked git worktrees, Codex scratch
+directories under `Documents/Codex`, and anything under `Downloads` are not
+offered.
+
+A large or malformed history can reach the scan limit. T3 Code keeps the
+projects it found and warns when projects or conversations may be missing.
+
+Imported projects include Claude, Codex and Pi conversations active within the last
+30 days. You can continue those conversations in T3 Code. Pi is off by default:
+open **Add more agents** and select **Turn on** next to Pi, and the page looks for
+its sessions too. Pi sessions
+are read from each enabled Pi provider's session directory, which follows
+`PI_CODING_AGENT_SESSION_DIR` and `PI_CODING_AGENT_DIR` in its environment. A
+Pi conversation imports its active branch, the one Pi reopens.
+
+Conversation import is best effort. T3 Code keeps the first user prompt and the
+newest remaining visible user and assistant messages, with 200 messages total.
+It omits tool activity and attachments. For Codex, it omits generated setup
+context only when a canonical user event and a valid shared turn ID identify the
+same user turn. Ambiguous legacy or response-only context stays in the imported
+conversation so T3 Code does not remove user text. It reads one conversation at
+a time and skips files larger than 4 GiB. It also skips a conversation when the
+message text and metadata it keeps need more than 32 MiB of memory. Tool output
+does not count toward that limit. It skips a conversation with more than 100,000
+records every time, so opening the page again does not import it. It ignores
+malformed records and skips unreadable or unparseable conversations.
+
+Each import attempt reads up to 100 conversation files and 4 GiB per project. To
+continue a large batch, open the Welcome page again and pick the project in
+**Review all**. Completed conversations are not imported again. You can continue
+without the remaining history.
+
+You can start without importing. While an import runs, T3 Code stays on the
+Welcome page. If you quit, open the Welcome page again from **Settings → About**; the
+projects you started stay checked.
diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md
deleted file mode 100644
index 6e3a9dc12f562..0000000000000
--- a/docs/user/welcome-wizard.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# Welcome wizard
-
-T3 Code shows a setup flow when you open a new installation. Existing
-workspaces skip this flow.
-
-The hosted app at app.t3.codes does not open setup. With no computer connected,
-it asks you to sign in to T3 Connect, then to install T3 Code and run
-`t3 connect` on your computer. From home, it adds compatible computers linked to your account and
-opens the app.
-
-Close setup while no import is running to go straight to the app. You can connect
-computers and agents later from Settings, or open `/welcome` to run setup again.
-Direct links, such as Settings, open normally before setup is done.
-
-## Connect your computers
-
-Select one or more computers to set up. If you opened T3 Code directly from a
-server or the desktop app, that computer is already connected and selected.
-It is identified by its name, which may differ from the device running your
-browser.
-
-You can add more computers before continuing:
-
-- **T3 Connect** connects computers that are signed in to your account.
- [Install the CLI](./install.md#command-line) and run `t3 connect` on each
- computer you want to add, then start T3 Code or run `t3 serve` so the
- computer stays available.
-- **Add a computer** connects directly to a server on your network or tailnet.
- Start the server with `t3 serve`, then run `t3 pair --tailscale` and paste
- the pairing link. You can also run `t3 serve --host ` and use
- `t3 pair` when the server is already reachable on your network.
-
-A computer marked **Client not supported** runs a T3 Code version that cannot
-talk to this app. The message under its name says which side to update.
-
-Saved computers and computers discovered through T3 Connect are selected by
-default. Uncheck any you do not want to set up; this does not disconnect them.
-Continue when your selected computers are connected. Setup checks
-agents across the selected computers, then offers project import grouped by computer.
-
-If T3 Code cannot confirm the workspace during startup, the setup flow shows
-**Still connecting** instead of opening the app. Select **Reload** to try again.
-
-If T3 Code cannot read your saved settings, it shows **Could not read settings**.
-Select **Retry** after storage becomes available. Setup does not replace
-unreadable settings with defaults.
-
-## Check your agents
-
-T3 Code checks each selected computer for Claude Code and Codex. If an agent is
-not installed or signed in, select its action to open a terminal with the
-correct command ready to run. Install uses the vendor's own installer, which
-keeps **Update now** working in Settings. Other providers can be enabled in
-Settings.
-
-The setup terminal uses the home directory and environment configured for the
-selected provider instance. Sensitive values remain redacted in Settings and
-terminal metadata while the terminal process can use them.
-
-## Import your projects
-
-T3 Code finds directories that Claude Code, Codex or Pi has used. Git repositories
-are listed first, newest activity on top. When the remote is on GitHub, the
-group shows the repository as `owner/name`. Clones with the same remote share
-one group. Directories that are not git repositories sit under "Other folders".
-
-The default selection includes git repositories active within the last 30 days
-with at least three conversations. Use the checkboxes, or "Select all" and
-"Select none", to change the selection. Linked worktrees of bare repositories
-can be offered as separate projects. Other linked git worktrees, Codex scratch
-directories under `Documents/Codex`, and anything under `Downloads` are not
-offered.
-
-A large or malformed history can reach the scan limit. T3 Code keeps the
-projects it found and warns when projects or conversations may be missing.
-
-Imported projects include Claude, Codex and Pi conversations active within the last
-30 days. You can continue those conversations in T3 Code. Pi is off by default:
-enable it in Settings, then open `/welcome` to import its sessions. Pi sessions
-are read from each enabled Pi provider's session directory, which follows
-`PI_CODING_AGENT_SESSION_DIR` and `PI_CODING_AGENT_DIR` in its environment. A
-Pi conversation imports its active branch, the one Pi reopens.
-
-Conversation import is best effort. T3 Code keeps the first user prompt and the
-newest remaining visible user and assistant messages, with 200 messages total.
-It omits tool activity and attachments. For Codex, it omits generated setup
-context only when a canonical user event and a valid shared turn ID identify the
-same user turn. Ambiguous legacy or response-only context stays in the imported
-conversation so T3 Code does not remove user text. It reads one conversation at
-a time and skips files larger than 4 GiB. It also skips a conversation when the
-message text and metadata it keeps need more than 32 MiB of memory. Tool output
-does not count toward that limit. It ignores malformed records and skips
-unreadable or unparseable conversations.
-
-Each import attempt reads up to 100 conversation files and 4 GiB per project,
-with up to 100,000 input records. Run import again to continue a large batch.
-Completed conversations are not imported again. You can continue without the
-remaining history.
-
-You can continue without configuring agents or importing projects, or return to an earlier step
-using the setup progress bar. Navigation pauses while an import is running.