1import { RemoteConnectionFlow } from "../settings/SelfHostedServer";
2import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
3import { useAuth } from "@clerk/react";
4import { useAtomValue } from "@effect/atom-react";
5import type {
6 AgentSessionProjectCandidate,
7 EnvironmentId,
8 ProjectId,
9 ProviderInstanceId,
10 ScopedProjectRef,
11 ServerConfig,
12 ServerProvider,
13} from "@t3tools/contracts";
14import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
15import {
16 isAtomCommandInterrupted,
17 squashAtomCommandFailure,
18} from "@t3tools/client-runtime/state/runtime";
19import {
20 AuthTerminalOperateScope,
21 CommandId,
22 defaultInstanceIdForDriver,
23 AuthOrchestrationOperateScope,
24 ProviderDriverKind,
25 ThreadId,
26} from "@t3tools/contracts";
27import * as Schema from "effect/Schema";
28import {
29 ArrowRightIcon,
30 CheckIcon,
31 ChevronRightIcon,
32 CloudIcon,
33 MonitorIcon,
34 TerminalIcon,
35} from "lucide-react";
36import { useCallback, useEffect, useMemo, useRef, useState } from "react";
37
38import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
39import { useLocalStorage } from "../../hooks/useLocalStorage";
40import { hasCloudPublicConfig } from "../../cloud/publicConfig";
41import { PRIVACY_POLICY_URL } from "../../legalLinks";
42import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
43import { useCompleteOnboarding } from "../../onboarding/firstRun";
44import {
45 groupOnboardingProjects,
46 partitionOnboardingProjects,
47 onboardingProjectKey,
48 resolveOnboardingLandingProject,
49 resolveOnboardingProjectId,
50 type OnboardingProjectGroup,
51 splitOnboardingProjectPath,
52} from "../../onboarding/projectImport.logic";
53import {
54 getOnboardingProviderState,
55 resolveOnboardingProviderInstallCommand,
56 resolveOnboardingProviderLoginCommand,
57 selectOnboardingProvidersByDriver,
58} from "../../onboarding/providerReadiness.logic";
59import { newProjectId, randomUUID } from "../../lib/utils";
60import { agentSessionImport } from "../../state/agentSessions";
61import { readProjects, useProjects } from "../../state/entities";
62import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
63import {
64 isOnboardingRelayEnvironment,
65 resolveOnboardingSetup,
66} from "../../onboarding/targetEnvironment.logic";
67import { useProjectScans } from "../../onboarding/useProjectScans";
68import { projectEnvironment } from "../../state/projects";
69import { serverEnvironment } from "../../state/server";
70import {
71 readEnvironmentScope,
72 useEnvironmentScope,
73 useEnvironmentsWithScope,
74} from "../../state/session";
75import { terminalEnvironment } from "../../state/terminal";
76import { useAtomCommand } from "../../state/use-atom-command";
77import { connectPairing } from "../../connection/onboarding";
78import { getProviderSummary } from "../settings/providerStatus";
79import { providerClients } from "../settings/providerDriverMeta";
80import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator";
81import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection";
82import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";
83import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";
84import { TerminalViewport } from "../ThreadTerminalDrawer";
85import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
86import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation";
87import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
88import { KnifeMark } from "../KnifeMark";
89import { Alert, AlertDescription } from "../ui/alert";
90import { Button } from "../ui/button";
91import { CommandBlock } from "../CommandBlock";
92import { Checkbox } from "../ui/checkbox";
93import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
94import { Input } from "../ui/input";
95import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
96import { ScrollArea } from "../ui/scroll-area";
97import { Spinner } from "../ui/spinner";
98import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard";
99import { Dialog } from "../ui/dialog";
100import { toastManager } from "../ui/toast";
101import { cn } from "../../lib/utils";
102import { formatRelativeTime } from "../../timestampFormat";
103
104/**
105 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
106 * fresh install (no completed-onboarding flag, empty workspace). Flow per the
107 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →
108 * managed Codex setup or an inline CLI terminal → project import → main screen.
109 * Every step past the connection gate is skippable; the whole wizard is
110 * re-runnable by clearing the flag.
111 */
112
113type WizardStep = "connection" | "agents" | "import";
114const NO_ENVIRONMENTS: readonly EnvironmentId[] = [];
115
116const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup");
117const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const;
118const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing.";
119
120export function WelcomeWizard({
121 localAvailable,
122 onDone,
123 resumeEnvironmentId,
124}: {
125 /** Whether this client is authenticated to the server serving the app. */
126 readonly localAvailable: boolean;
127 readonly resumeEnvironmentId?: EnvironmentId | undefined;
128 readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;
129}) {
130 const completeOnboarding = useCompleteOnboarding();
131 const [step, setStep] = useState<WizardStep>(resumeEnvironmentId ? "agents" : "connection");
132 const { environments } = useEnvironments();
133 const [selection, setSelection] = useState<ReadonlySet<EnvironmentId> | null>(null);
134 const autoSelectedComputers = useRef(new Set<EnvironmentId>());
135 const [setupIds, setSetupIds] = useState<readonly EnvironmentId[]>(
136 resumeEnvironmentId ? [resumeEnvironmentId] : [],
137 );
138 const [isImporting, setIsImporting] = useState(false);
139 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);
140 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
141 const primaryEnvironment = usePrimaryEnvironment();
142 useEffect(() => {
143 const newComputers = environments.filter(
144 (environment) => !autoSelectedComputers.current.has(environment.environmentId),
145 );
146 if (newComputers.length === 0) return;
147 for (const environment of newComputers) {
148 autoSelectedComputers.current.add(environment.environmentId);
149 }
150 // A computer the user switched off stays unselected until they pick it.
151 const enabledComputers = newComputers.filter((environment) => environment.entry.enabled);
152 if (enabledComputers.length === 0) return;
153 setSelection(
154 (current) =>
155 new Set([
156 ...(current ?? []),
157 ...enabledComputers.map((environment) => environment.environmentId),
158 ]),
159 );
160 }, [environments]);
161 const selectedIds =
162 selection ?? new Set(primaryEnvironment ? [primaryEnvironment.environmentId] : []);
163 const scans = useProjectScans(step === "import" ? setupIds : NO_ENVIRONMENTS);
164 const isLoadingProjects =
165 step === "import" &&
166 scans.every((scan) => scan.data === null) &&
167 scans.some((scan) => scan.isPending);
168 const startSetup = (ids: readonly EnvironmentId[]) => {
169 if (ids.length === 0) return;
170 setSetupIds(ids);
171 setStep("agents");
172 };
173 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
174 const finish = useCallback(
175 (projectRef?: ScopedProjectRef, importWarning?: string, importedThreadCount = 0) => {
176 if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
177 if (completionErrorToastIdRef.current !== null) {
178 toastManager.close(completionErrorToastIdRef.current);
179 completionErrorToastIdRef.current = null;
180 }
181
182 const completion = completeOnboarding()
183 .then(async () => {
184 if (completionErrorToastIdRef.current !== null) {
185 toastManager.close(completionErrorToastIdRef.current);
186 completionErrorToastIdRef.current = null;
187 }
188 await onDone(projectRef);
189 if (importWarning) {
190 toastManager.add({
191 type: "warning",
192 title: "Some history was not imported",
193 description: importWarning,
194 timeout: 0,
195 });
196 } else if (importedThreadCount > 0) {
197 toastManager.add({
198 type: "success",
199 title: `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}`,
200 });
201 }
202 return true;
203 })
204 .catch(() => {
205 const errorToast = {
206 type: "error",
207 title: "Could not finish setup",
208 description: "Your settings could not be saved. Try again.",
209 } as const;
210 if (completionErrorToastIdRef.current === null) {
211 completionErrorToastIdRef.current = toastManager.add(errorToast);
212 } else {
213 toastManager.update(completionErrorToastIdRef.current, errorToast);
214 }
215 return false;
216 })
217 .finally(() => {
218 if (finishingPromiseRef.current === completion) {
219 finishingPromiseRef.current = null;
220 }
221 });
222 finishingPromiseRef.current = completion;
223 return completion;
224 },
225 [completeOnboarding, onDone],
226 );
227
228 return (
229 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
230 <WizardPopup
231 size="wide"
232 bottomStickOnMobile={false}
233 showCloseButton={false}
234 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
235 >
236 <WizardHeader
237 title="Set up The Knife"
238 identity={
239 <div className="flex items-center gap-2" role="img" aria-label="The Knife">
240 <KnifeMark className="size-8 shrink-0" aria-hidden />
241 <span className="text-2xl font-medium tracking-tight text-muted-foreground">
242 The Knife
243 </span>
244 </div>
245 }
246 >
247 <WizardSteps
248 steps={ONBOARDING_STAGES}
249 currentStep={stageIndex}
250 isStepDisabled={(index) => isImporting || index >= stageIndex}
251 onStepChange={(index) => {
252 if (isImporting || index > stageIndex) return;
253 setStep(index === 0 ? "connection" : "agents");
254 }}
255 />
256 </WizardHeader>
257
258 <WizardPanel holdHeight={isLoadingProjects}>
259 {step === "connection" ? (
260 <ConnectionStep
261 selectedIds={selectedIds}
262 autoSelectedComputers={autoSelectedComputers.current}
263 onSelectionChange={setSelection}
264 onToggleEnvironment={(environmentId, checked) =>
265 setSelection((current) => {
266 const next = new Set(current ?? selectedIds);
267 if (checked) next.add(environmentId);
268 else next.delete(environmentId);
269 return next;
270 })
271 }
272 onContinue={() =>
273 startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds)
274 }
275 onPaired={(environmentId) => {
276 setSelection(new Set([...selectedIds, environmentId]));
277 }}
278 />
279 ) : step === "agents" ? (
280 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
281 ) : (
282 <ImportStep
283 scans={scans}
284 isImporting={isImporting}
285 setIsImporting={setIsImporting}
286 onDone={finish}
287 />
288 )}
289 </WizardPanel>
290 </WizardPopup>
291 <ChatGptWelcomeCoordinator />
292 </Dialog>
293 );
294}
295
296// ── Step 1: connection choice ────────────────────────────────
297
298function ConnectionStep({
299 autoSelectedComputers,
300 selectedIds,
301 onSelectionChange,
302 onToggleEnvironment,
303 onContinue,
304 onPaired,
305}: {
306 readonly autoSelectedComputers: Set<EnvironmentId>;
307 readonly selectedIds: ReadonlySet<EnvironmentId>;
308 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId>) => void;
309 readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
310 readonly onContinue: () => void;
311 readonly onPaired: (environmentId: EnvironmentId) => void;
312}) {
313 const { environments } = useEnvironments();
314 const cloudEnabled = hasCloudPublicConfig();
315 const directEnvironments = environments.filter(
316 (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment),
317 );
318 const [pairingOpen, setPairingOpen] = useState(false);
319 const [isPairing, setIsPairing] = useState(false);
320 const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds);
321 const skippedLabels = environments
322 .filter((environment) => skippedIds.includes(environment.environmentId))
323 .map((environment) => environment.label);
324 const continueRef = useRef<HTMLButtonElement>(null);
325 useEffect(() => {
326 if (
327 ready &&
328 (document.activeElement === document.body ||
329 document.activeElement?.getAttribute("role") === "dialog")
330 ) {
331 continueRef.current?.focus();
332 }
333 }, [ready]);
334 return (
335 <>
336 <h1 className="text-2xl font-semibold tracking-tight text-foreground">
337 Connect your computers
338 </h1>
339 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
340 Choose one or more computers. We’ll set up agents and projects on each.
341 </p>
342 {directEnvironments.length > 0 ? (
343 <fieldset className="mt-5 space-y-2">
344 <legend className="sr-only">Computers to set up</legend>
345 {directEnvironments.map((environment) => (
346 <label
347 key={environment.environmentId}
348 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-3"
349 >
350 <Checkbox
351 checked={selectedIds.has(environment.environmentId)}
352 onCheckedChange={(checked) => {
353 const next = new Set(selectedIds);
354 if (checked) next.add(environment.environmentId);
355 else next.delete(environment.environmentId);
356 onSelectionChange(next);
357 }}
358 />
359 <MonitorIcon className="size-4 shrink-0 text-muted-foreground" />
360 <span className="min-w-0 flex-1">
361 <span className="flex items-baseline justify-between gap-3">
362 <span className="min-w-0 text-sm font-medium break-words">
363 {environment.label}
364 </span>
365 <span className="shrink-0 text-xs text-muted-foreground">
366 {/* Same rule as Settings: a switched-off computer reads Off, not Not connected. */}
367 {!environment.entry.enabled && environment.connection.phase !== "unsupported"
368 ? "Off"
369 : presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
370 </span>
371 </span>
372 {environment.displayUrl ? (
373 <span className="mt-0.5 block text-xs break-all text-muted-foreground">
374 {environment.displayUrl}
375 </span>
376 ) : null}
377 </span>
378 </label>
379 ))}
380 </fieldset>
381 ) : null}
382 <div className="mt-4 space-y-2">
383 {!pairingOpen ? (
384 <RemoteConnectionFlow
385 onDirect={() => setPairingOpen(true)}
386 cloudContent={
387 <CloudAuthBoundary
388 fallback={
389 <p role="status" className="text-sm text-muted-foreground">
390 Loading sign-in…
391 </p>
392 }
393 >
394 <ConnectAccountOption
395 autoSelectedComputers={autoSelectedComputers}
396 disabled={isPairing}
397 selectedIds={selectedIds}
398 onToggleEnvironment={onToggleEnvironment}
399 />
400 </CloudAuthBoundary>
401 }
402 />
403 ) : (
404 <div className="space-y-3 rounded-lg border border-border bg-background p-3">
405 <Button
406 variant="ghost"
407 size="sm"
408 disabled={isPairing}
409 onClick={() => setPairingOpen(false)}
410 >
411 Back
412 </Button>
413 <PairingForm
414 isPairing={isPairing}
415 setIsPairing={setIsPairing}
416 onPaired={(environmentId) => {
417 setPairingOpen(false);
418 onPaired(environmentId);
419 requestAnimationFrame(() => continueRef.current?.focus());
420 }}
421 />
422 </div>
423 )}
424 </div>
425 {skippedLabels.length > 0 ? (
426 <p className="mt-4 text-xs text-muted-foreground">
427 Not connected, so setup skips {skippedLabels.join(", ")}. You can set{" "}
428 {skippedLabels.length === 1 ? "it" : "them"} up later from Settings.
429 </p>
430 ) : null}
431 <div className="mt-6 flex flex-col-reverse gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
432 <p className="min-w-0 text-xs leading-relaxed text-muted-foreground">
433 Anonymous usage data helps improve T3 Code. To read more about how your data is used and
434 how to opt out, see our{" "}
435 <a
436 className="underline underline-offset-2 hover:text-foreground"
437 href={PRIVACY_POLICY_URL}
438 target="_blank"
439 rel="noreferrer noopener"
440 >
441 privacy policy
442 </a>
443 .
444 </p>
445 <Button
446 className="shrink-0 self-end"
447 ref={continueRef}
448 autoFocus={ready}
449 disabled={!ready || isPairing}
450 onClick={onContinue}
451 >
452 Continue
453 <ArrowRightIcon className="size-3.5" />
454 </Button>
455 </div>
456 </>
457 );
458}
459
460function ConnectAccountOption({
461 autoSelectedComputers,
462 disabled,
463 selectedIds,
464 onToggleEnvironment,
465}: {
466 readonly autoSelectedComputers: Set<EnvironmentId>;
467 readonly disabled: boolean;
468 readonly selectedIds: ReadonlySet<EnvironmentId>;
469 readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
470}) {
471 const { environments } = useEnvironments();
472 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
473 const { openAuthPrompt } = useT3ConnectAuthPrompt();
474 const [expanded, setExpanded] = useState(true);
475 const [discoveryReady, setDiscoveryReady] = useState(false);
476 const onDiscoveryReady = useCallback(() => setDiscoveryReady(true), []);
477
478 return (
479 <div className="rounded-lg border border-border bg-background">
480 <Collapsible open={expanded && !!isSignedIn && discoveryReady} onOpenChange={setExpanded}>
481 <CollapsibleTrigger
482 disabled={disabled || !isLoaded}
483 onClick={(event) => {
484 if (!isSignedIn) {
485 event.preventDefault();
486 setExpanded(true);
487 openAuthPrompt();
488 }
489 }}
490 render={
491 <Button variant="ghost" size="sm-multiline" className="min-h-14 w-full justify-start" />
492 }
493 >
494 <CloudIcon className="size-4 text-muted-foreground" />
495 <span className="flex-1 text-left">T3 Connect</span>
496 <span className="text-xs text-muted-foreground">
497 {!isLoaded
498 ? "Loading sign-in…"
499 : !isSignedIn
500 ? "Sign in"
501 : !discoveryReady
502 ? "Loading computers…"
503 : null}
504 </span>
505 <ChevronRightIcon
506 className={cn("size-4 text-muted-foreground", expanded && isSignedIn && "rotate-90")}
507 />
508 </CollapsibleTrigger>
509 <CollapsiblePanel keepMounted>
510 <div className="px-3 pb-3">
511 <div className="mb-3 space-y-1.5">
512 {isSignedIn ? (
513 <CloudEnvironmentConnectRows
514 primaryEnvironmentId={null}
515 savedEnvironments={environments}
516 showSavedEnvironments
517 onDiscoveryReady={onDiscoveryReady}
518 selection={{ selectedIds, onChange: onToggleEnvironment, autoSelectedComputers }}
519 refreshWhileEmpty
520 empty={
521 <p className="py-3 text-sm text-muted-foreground">No computers linked yet.</p>
522 }
523 />
524 ) : null}
525 </div>
526 <p className="text-sm text-muted-foreground">
527 Run this on each computer you want to connect.
528 </p>
529 <CommandBlock command="npx t3 connect" className="mt-3" />
530 <p className="mt-3 text-xs text-muted-foreground">
531 Keep The Knife running. Select the computers you want to set up above.
532 </p>
533 </div>
534 </CollapsiblePanel>
535 </Collapsible>
536 </div>
537 );
538}
539
540// ── Step 2′: Direct pairing ──────────────────────────────────
541
542/**
543 * Register a computer in this browser using a server-minted pairing link.
544 */
545function PairingForm({
546 isPairing,
547 setIsPairing,
548 onPaired,
549}: {
550 readonly isPairing: boolean;
551 readonly setIsPairing: (value: boolean) => void;
552 readonly onPaired: (environmentId: EnvironmentId) => void;
553}) {
554 const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false });
555 const [pairingUrl, setPairingUrl] = useState("");
556 const [errorMessage, setErrorMessage] = useState("");
557 const mountedRef = useRef(true);
558
559 useEffect(() => {
560 mountedRef.current = true;
561 return () => {
562 mountedRef.current = false;
563 };
564 }, []);
565
566 const submit = async () => {
567 if (isPairing || pairingUrl.trim().length === 0) return;
568 setIsPairing(true);
569 setErrorMessage("");
570 const result = await connectPairingEnvironment({ pairingUrl: pairingUrl.trim() });
571 if (!mountedRef.current) return;
572 setIsPairing(false);
573 if (result._tag === "Success") {
574 onPaired(result.value);
575 return;
576 }
577 if (isAtomCommandInterrupted(result)) return;
578 const cause = squashAtomCommandFailure(result);
579 setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed.");
580 };
581
582 return (
583 <>
584 <form
585 className="space-y-3"
586 onSubmit={(event) => {
587 event.preventDefault();
588 void submit();
589 }}
590 >
591 <div>
592 <label className="block text-sm text-muted-foreground" htmlFor="onboarding-pairing-url">
593 Pairing link
594 </label>
595 <Input
596 id="onboarding-pairing-url"
597 autoFocus
598 aria-invalid={errorMessage.length > 0}
599 aria-describedby={errorMessage ? "onboarding-pairing-error" : undefined}
600 className="mt-2"
601 size="lg"
602 autoCapitalize="none"
603 autoComplete="off"
604 autoCorrect="off"
605 spellCheck={false}
606 nativeInput
607 readOnly={isPairing}
608 placeholder="https://your-server:5230/pair#token=…"
609 value={pairingUrl}
610 onChange={(event) => setPairingUrl(event.currentTarget.value)}
611 onKeyDown={(event) => {
612 if (
613 event.key === "Enter" &&
614 (event.nativeEvent.isComposing || event.keyCode === 229)
615 ) {
616 event.preventDefault();
617 }
618 }}
619 />
620 </div>
621 {errorMessage ? (
622 <Alert id="onboarding-pairing-error" variant="error">
623 <AlertDescription>{errorMessage}</AlertDescription>
624 </Alert>
625 ) : null}
626 <Collapsible>
627 <div className="flex items-center justify-between gap-3">
628 <CollapsibleTrigger
629 type="button"
630 className="group flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
631 >
632 <ChevronRightIcon className="size-3.5 group-data-panel-open:rotate-90" />
633 Need a pairing link?
634 </CollapsibleTrigger>
635 <Button type="submit" disabled={isPairing || pairingUrl.trim().length === 0}>
636 {isPairing ? "Pairing..." : "Pair"}
637 </Button>
638 </div>
639 <CollapsiblePanel>
640 <p className="pt-3 text-sm text-muted-foreground">
641 Run this on the computer with your code.
642 </p>
643 <CommandBlock command="npx t3 pair" className="mt-2" />
644 <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
645 Start The Knife first, or run <code className="font-mono">npx t3 serve</code>. Add{" "}
646 <code className="font-mono">--tailscale</code> to use your tailnet.
647 </p>
648 </CollapsiblePanel>
649 </Collapsible>
650 </form>
651 </>
652 );
653}
654
655// ── Step 3: agents ───────────────────────────────────────────
656
657const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const;
658type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];
659
660/** Setup values stay fixed while provider probes refresh the surrounding cards. */
661interface AgentTerminalSession {
662 readonly environmentId: EnvironmentId;
663 readonly driver: OnboardingAgentDriver;
664 readonly providerInstanceId: ServerProvider["instanceId"];
665 readonly cwd: string;
666 readonly command: string;
667 readonly keybindings: ServerConfig["keybindings"];
668}
669
670/** Codex uses managed setup; existing CLI installs retain the terminal path. */
671function AgentsStep({
672 environmentIds,
673 onContinue,
674}: {
675 readonly environmentIds: readonly EnvironmentId[];
676 readonly onContinue: () => void;
677}) {
678 const { environments } = useEnvironments();
679 return (
680 <StepShell
681 title="Connect your agents"
682 description="Choose an agent to start coding. You can add more later."
683 >
684 <ScrollArea scrollFade className="mt-5 h-auto max-h-[min(32rem,55dvh)]">
685 <div className="space-y-5 pr-3">
686 {environmentIds.map((environmentId) => (
687 <ConnectedAgentsStep
688 key={environmentId}
689 environmentId={environmentId}
690 machineLabel={
691 environments.find((environment) => environment.environmentId === environmentId)
692 ?.label ?? "Computer"
693 }
694 />
695 ))}
696 </div>
697 </ScrollArea>
698 <div className="mt-6 flex justify-end">
699 <Button autoFocus onClick={onContinue}>
700 Continue
701 <ArrowRightIcon className="size-3.5" />
702 </Button>
703 </div>
704 </StepShell>
705 );
706}
707
708function ConnectedAgentsStep({
709 environmentId,
710 machineLabel,
711}: {
712 readonly environmentId: EnvironmentId;
713 readonly machineLabel: string;
714}) {
715 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
716 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
717 reportFailure: false,
718 });
719 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
720 const [terminalSession, setTerminalSession] = useState<AgentTerminalSession | null>(null);
721 const [addingAccount, setAddingAccount] = useState(false);
722 const [createdAccount, setCreatedAccount] = useState<{
723 instanceId: ProviderInstanceId;
724 displayName: string;
725 autoStart: boolean;
726 } | null>(null);
727 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
728
729 // Re-probe on entry so freshly installed CLIs show up without a manual
730 // refresh; harmless when nothing changed (single-flighted per environment).
731 useEffect(() => {
732 void refreshProviders({ environmentId, input: {} });
733 }, [environmentId, refreshProviders]);
734
735 const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]);
736
737 const primaryAgents = PRIMARY_AGENT_DRIVERS.flatMap((driver) => {
738 const instances =
739 driver === "codex" ? providers?.filter((provider) => provider.driver === driver) : undefined;
740 return instances?.length
741 ? instances.map((provider) => ({ driver, provider, instanceId: provider.instanceId }))
742 : [{ driver, provider: byDriver.get(driver), instanceId: byDriver.get(driver)?.instanceId }];
743 });
744 // Keep the newly created row mounted while settings and provider snapshots catch up.
745 if (createdAccount) {
746 const index = primaryAgents.findIndex(
747 (agent) => agent.instanceId === createdAccount.instanceId,
748 );
749 const [existing] = index >= 0 ? primaryAgents.splice(index, 1) : [];
750 primaryAgents.unshift(
751 existing ?? {
752 driver: "codex",
753 provider: undefined,
754 instanceId: createdAccount.instanceId,
755 },
756 );
757 }
758 return (
759 <section>
760 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>
761 {!canOperateTerminal ? (
762 <p className="mb-2 text-sm text-muted-foreground">
763 This connection cannot control terminals.
764 </p>
765 ) : null}
766 <div className="space-y-1.5">
767 {primaryAgents.map(({ driver, provider, instanceId }) =>
768 driver === "codex" && serverConfig !== null ? (
769 <OnboardingCodexSetup
770 key={instanceId ?? driver}
771 environmentId={environmentId}
772 provider={provider}
773 serverConfig={serverConfig}
774 createdAccount={instanceId === createdAccount?.instanceId ? createdAccount : null}
775 onAutoStartConsumed={() =>
776 setCreatedAccount((account) => (account ? { ...account, autoStart: false } : null))
777 }
778 terminalOpen={terminalSession?.driver === driver}
779 onOpenTerminal={() => {
780 if (
781 provider === undefined ||
782 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
783 )
784 return;
785 setTerminalSession({
786 environmentId,
787 driver,
788 providerInstanceId: provider.instanceId,
789 cwd: serverConfig.cwd,
790 command: provider.installed
791 ? resolveOnboardingProviderLoginCommand(
792 provider,
793 serverConfig.settings,
794 serverConfig.environment.platform.os,
795 )
796 : resolveOnboardingProviderInstallCommand(
797 driver,
798 serverConfig.environment.platform.os,
799 ),
800 keybindings: serverConfig.keybindings,
801 });
802 }}
803 />
804 ) : (
805 <AgentCard
806 key={driver}
807 driver={driver}
808 provider={provider}
809 terminalOpen={terminalSession?.driver === driver}
810 terminalAvailable={serverConfig !== null && canOperateTerminal}
811 onOpenTerminal={() => {
812 if (
813 provider === undefined ||
814 serverConfig === null ||
815 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
816 )
817 return;
818 setTerminalSession({
819 environmentId,
820 driver,
821 providerInstanceId: provider.instanceId,
822 cwd: serverConfig.cwd,
823 command: provider.installed
824 ? resolveOnboardingProviderLoginCommand(
825 provider,
826 serverConfig.settings,
827 serverConfig.environment.platform.os,
828 )
829 : resolveOnboardingProviderInstallCommand(
830 driver,
831 serverConfig.environment.platform.os,
832 ),
833 keybindings: serverConfig.keybindings,
834 });
835 }}
836 />
837 ),
838 )}
839 </div>
840 {providers?.some(
841 (provider) =>
842 provider.driver === "codex" && getOnboardingProviderState(provider) === "ready",
843 ) ? (
844 <div className="mt-3">
845 <Button size="xs" variant="ghost-muted" onClick={() => setAddingAccount(true)}>
846 Connect another ChatGPT account
847 </Button>
848 </div>
849 ) : null}
850 {addingAccount ? (
851 <AddManagedCodexAccountDialog
852 environmentId={environmentId}
853 onClose={() => setAddingAccount(false)}
854 onAccountCreated={(instanceId, displayName) =>
855 setCreatedAccount({ instanceId, displayName, autoStart: true })
856 }
857 />
858 ) : null}
859 {terminalSession !== null ? (
860 <AgentInstallTerminal
861 key={`${terminalSession.environmentId}:${terminalSession.providerInstanceId}:${terminalSession.driver}`}
862 session={terminalSession}
863 onClose={() => {
864 setTerminalSession(null);
865 void refreshProviders({ environmentId, input: {} });
866 }}
867 />
868 ) : null}
869 </section>
870 );
871}
872
873function OnboardingCodexSetup({
874 createdAccount,
875 onAutoStartConsumed,
876 environmentId,
877 provider,
878 serverConfig,
879 terminalOpen,
880 onOpenTerminal,
881}: {
882 readonly environmentId: EnvironmentId;
883 readonly provider: ServerProvider | undefined;
884 readonly serverConfig: ServerConfig;
885 readonly terminalOpen: boolean;
886 readonly onOpenTerminal: () => void;
887 readonly createdAccount: {
888 instanceId: ProviderInstanceId;
889 displayName: string;
890 autoStart: boolean;
891 } | null;
892 readonly onAutoStartConsumed: () => void;
893}) {
894 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
895 const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");
896 const instanceId =
897 createdAccount?.instanceId ??
898 provider?.instanceId ??
899 defaultInstanceIdForDriver(ProviderDriverKind.make("codex"));
900 const settings = serverConfig.settings;
901 const instance = settings.providerInstances[instanceId] ?? {
902 driver: ProviderDriverKind.make("codex"),
903 config: createdAccount ? { enabled: true, setupMode: "managed" } : {},
904 };
905 const mode = readCodexSetupMode(instance.config);
906 const existingChosen =
907 mode === "existing" &&
908 instance.config !== null &&
909 typeof instance.config === "object" &&
910 "setupMode" in instance.config &&
911 instance.config.setupMode === "existing";
912 const changeMode = (setupMode: "managed" | "existing") => {
913 void update({
914 environmentId,
915 input: {
916 patch: buildProviderInstanceUpdatePatch({
917 settings,
918 instanceId,
919 instance: {
920 ...instance,
921 enabled: true,
922 config: {
923 ...(instance.config !== null && typeof instance.config === "object"
924 ? instance.config
925 : {}),
926 enabled: true,
927 setupMode,
928 },
929 },
930 }),
931 },
932 });
933 };
934 return existingChosen ? (
935 <AgentCard
936 driver="codex"
937 provider={provider}
938 terminalOpen={terminalOpen}
939 terminalAvailable={canOperateTerminal}
940 onOpenTerminal={onOpenTerminal}
941 />
942 ) : (
943 <CodexSetupSection
944 presentation="onboarding"
945 autoStart={createdAccount?.autoStart === true}
946 displayName={createdAccount?.displayName}
947 onAutoStartConsumed={onAutoStartConsumed}
948 environmentId={environmentId}
949 instanceId={instanceId}
950 provider={provider}
951 mode={mode}
952 enabled={provider?.enabled ?? true}
953 onModeChange={changeMode}
954 />
955 );
956}
957
958function AgentCard({
959 driver,
960 provider,
961 terminalOpen,
962 terminalAvailable,
963 onOpenTerminal,
964}: {
965 readonly driver: OnboardingAgentDriver;
966 readonly provider: ServerProvider | undefined;
967 readonly terminalOpen: boolean;
968 readonly terminalAvailable: boolean;
969 readonly onOpenTerminal: () => void;
970}) {
971 const meta = providerClients.get(ProviderDriverKind.make(driver));
972 const displayName =
973 provider?.displayName || (driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver));
974 const summary = getProviderSummary(provider);
975 const providerState = getOnboardingProviderState(provider);
976
977 return (
978 <div className="flex items-center gap-3 rounded-lg border border-border bg-background px-4 py-4">
979 <ProviderInstanceIcon
980 driverKind={ProviderDriverKind.make(driver)}
981 displayName={displayName}
982 iconClassName="size-5"
983 />
984 <div className="min-w-0 flex-1">
985 <span className="block text-sm font-medium text-foreground">{displayName}</span>
986 <p className="mt-0.5 text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground">
987 {providerState === "ready" ? "Ready to code." : summary.headline}
988 {providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}
989 </p>
990 </div>
991 <div className="shrink-0">
992 {providerState === "ready" ? (
993 <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
994 <CheckIcon className="size-3.5" />
995 Ready
996 </span>
997 ) : providerState === "checking" ? (
998 <span className="text-xs text-muted-foreground">Checking...</span>
999 ) : providerState === "disabled" ? (
1000 <span className="text-xs text-muted-foreground">Disabled</span>
1001 ) : providerState === "attention" ? (
1002 <span className="text-xs text-muted-foreground">{summary.headline}</span>
1003 ) : (
1004 <Button
1005 size="xs"
1006 variant="ghost"
1007 onClick={onOpenTerminal}
1008 disabled={terminalOpen || !terminalAvailable}
1009 >
1010 <TerminalIcon className="size-3.5" />
1011 {providerState === "signIn" ? "Sign in" : "Install"}
1012 </Button>
1013 )}
1014 </div>
1015 </div>
1016 );
1017}
1018
1019/**
1020 * Inline install terminal. Opens a PTY on the connected environment under a
1021 * synthetic onboarding thread id (terminals are keyed by free-form thread id;
1022 * the server validates only the cwd) and pre-types the install or login
1023 * command without submitting, so the user reviews and presses Enter.
1024 */
1025function AgentInstallTerminal({
1026 session,
1027 onClose,
1028}: {
1029 readonly session: AgentTerminalSession;
1030 readonly onClose: () => void;
1031}) {
1032 const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;
1033 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
1034 // Same terminal typography preference the thread drawer honors.
1035 const [advancedTypography] = useLocalStorage(
1036 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
1037 false,
1038 Schema.Boolean,
1039 );
1040 const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false });
1041 const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false });
1042 const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
1043 const setupQueueRef = useRef(Promise.resolve());
1044 const setupGenerationRef = useRef(0);
1045 const activeSetupGenerationRef = useRef<number | null>(null);
1046 const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`);
1047 const threadRef = useMemo(
1048 () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID),
1049 [environmentId],
1050 );
1051 const [setupAttempt, setSetupAttempt] = useState(0);
1052 const [setupState, setSetupState] = useState<
1053 "preparing" | "ready" | "openFailed" | "writeFailed"
1054 >("preparing");
1055 const terminalReady = setupState === "ready" || setupState === "writeFailed";
1056
1057 // Keep each setup generation distinct. In Strict Mode, a canceled open can
1058 // finish after the replacement setup starts; it must not close or pre-type
1059 // into the replacement session that shares this terminal id.
1060 useEffect(() => {
1061 const generation = setupGenerationRef.current + 1;
1062 setupGenerationRef.current = generation;
1063 activeSetupGenerationRef.current = generation;
1064 setSetupState("preparing");
1065
1066 setupQueueRef.current = setupQueueRef.current.then(async () => {
1067 if (activeSetupGenerationRef.current !== generation) return;
1068 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1069 setSetupState("openFailed");
1070 return;
1071 }
1072 const opened = await openTerminal({
1073 environmentId,
1074 input: {
1075 threadId: AGENT_ONBOARDING_THREAD_ID,
1076 terminalId,
1077 cwd,
1078 providerInstanceId,
1079 },
1080 });
1081 if (opened._tag !== "Success") {
1082 if (activeSetupGenerationRef.current === generation) setSetupState("openFailed");
1083 return;
1084 }
1085
1086 if (activeSetupGenerationRef.current !== generation) return;
1087 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1088 setSetupState("writeFailed");
1089 return;
1090 }
1091
1092 const wrote = await writeTerminal({
1093 environmentId,
1094 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command },
1095 });
1096 if (activeSetupGenerationRef.current !== generation) return;
1097 setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");
1098 });
1099
1100 // Every exit path unmounts the drawer. Close the PTY only while this
1101 // connection still has terminal access; revocation leaves it running.
1102 return () => {
1103 if (activeSetupGenerationRef.current === generation) {
1104 activeSetupGenerationRef.current = null;
1105 }
1106 setupQueueRef.current = setupQueueRef.current.then(async () => {
1107 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) return;
1108 await closeTerminal({
1109 environmentId,
1110 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },
1111 });
1112 });
1113 };
1114 }, [
1115 closeTerminal,
1116 command,
1117 cwd,
1118 environmentId,
1119 openTerminal,
1120 providerInstanceId,
1121 setupAttempt,
1122 terminalId,
1123 writeTerminal,
1124 ]);
1125
1126 return (
1127 <div
1128 data-thread-terminal-drawer
1129 className="mt-4 overflow-hidden rounded-lg border border-border/70 bg-background text-foreground"
1130 >
1131 <div className="flex items-center justify-between border-b border-border/60 bg-background/60 px-3 py-1.5">
1132 <span className="text-2xs font-medium text-muted-foreground">
1133 {!canOperateTerminal ? (
1134 "This connection cannot control terminals."
1135 ) : setupState === "writeFailed" ? (
1136 <>
1137 Run <code className="rounded bg-muted px-1 font-mono">{command}</code> in this
1138 terminal.
1139 </>
1140 ) : setupState === "ready" ? (
1141 "Review the command, then press Enter to run it."
1142 ) : setupState === "openFailed" ? (
1143 "Could not open the setup terminal."
1144 ) : (
1145 "Preparing command..."
1146 )}
1147 </span>
1148 <div className="flex items-center gap-1">
1149 {setupState === "openFailed" ? (
1150 <Button
1151 size="xs"
1152 variant="ghost"
1153 disabled={!canOperateTerminal}
1154 onClick={() => {
1155 if (readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1156 setSetupAttempt((value) => value + 1);
1157 }
1158 }}
1159 >
1160 Retry
1161 </Button>
1162 ) : null}
1163 <Button size="xs" variant="ghost-muted" onClick={onClose}>
1164 Close
1165 </Button>
1166 </div>
1167 </div>
1168 <div className="h-64">
1169 {terminalReady ? (
1170 <TerminalViewport
1171 threadRef={threadRef}
1172 threadId={AGENT_ONBOARDING_THREAD_ID}
1173 terminalId={terminalId}
1174 terminalLabel={`Install ${driver}`}
1175 cwd={cwd}
1176 providerInstanceId={providerInstanceId}
1177 advancedTypography={advancedTypography}
1178 onSessionExited={onClose}
1179 focusRequestId={1}
1180 autoFocus
1181 visible
1182 resizeEpoch={0}
1183 drawerHeight={256}
1184 keybindings={keybindings}
1185 />
1186 ) : null}
1187 </div>
1188 </div>
1189 );
1190}
1191
1192// ── Step 4: import ───────────────────────────────────────────
1193
1194const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history.";
1195
1196function ImportStep({
1197 scans,
1198 isImporting,
1199 setIsImporting,
1200 onDone,
1201}: {
1202 readonly scans: ReturnType<typeof useProjectScans>;
1203 readonly isImporting: boolean;
1204 readonly setIsImporting: (value: boolean) => void;
1205 readonly onDone: (
1206 projectRef?: ScopedProjectRef,
1207 importWarning?: string,
1208 importedThreadCount?: number,
1209 ) => Promise<boolean>;
1210}) {
1211 const { environments } = useEnvironments();
1212 const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope);
1213 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
1214 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
1215 const projects = useProjects();
1216 const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
1217 const importWarningRef = useRef("");
1218 const importedThreadCountRef = useRef(0);
1219 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
1220 // Keep project creation attempts separate from completed history imports so both can retry.
1221 const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
1222 const projectsWithImportedHistoryRef = useRef(new Map<string, ScopedProjectRef>());
1223 const lastImportSelectionRef = useRef<ReadonlyArray<string>>([]);
1224 const projectAttemptsRef = useRef(
1225 new Map<string, { readonly projectId: ProjectId; readonly commandId: CommandId }>(),
1226 );
1227 const importGenerationRef = useRef(0);
1228
1229 // Ignore command completions after leaving the import step.
1230 useEffect(() => {
1231 importGenerationRef.current += 1;
1232 return () => {
1233 importGenerationRef.current += 1;
1234 };
1235 }, []);
1236
1237 useEffect(() => {
1238 if (
1239 landingProject !== null &&
1240 projects.some(
1241 (project) =>
1242 project.id === landingProject.projectId &&
1243 project.environmentId === landingProject.environmentId,
1244 )
1245 ) {
1246 setLandingProject(null);
1247 void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then(
1248 (completed) => {
1249 if (!completed) setIsImporting(false);
1250 },
1251 );
1252 }
1253 }, [landingProject, onDone, projects, setIsImporting]);
1254
1255 const { available: candidates, recent } = useMemo(
1256 () =>
1257 partitionOnboardingProjects(
1258 scans.flatMap((scan) =>
1259 (scan.data?.candidates ?? []).map((candidate) => ({
1260 ...candidate,
1261 environmentId: scan.environmentId,
1262 key: onboardingProjectKey(scan.environmentId, candidate.path),
1263 })),
1264 ),
1265 ),
1266 [scans],
1267 );
1268 const selectedKeys = useMemo(
1269 () => selectedPaths ?? new Set(recent.map((candidate) => candidate.key)),
1270 [selectedPaths, recent],
1271 );
1272 const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key));
1273
1274 const canImport = selected.every((candidate) =>
1275 writableEnvironments.has(candidate.environmentId),
1276 );
1277 const [importError, setImportError] = useState("");
1278 const visibleImportError = !canImport
1279 ? IMPORT_PERMISSION_MESSAGE
1280 : importError === IMPORT_PERMISSION_MESSAGE
1281 ? ""
1282 : importError;
1283
1284 const finishAfterImport = () => {
1285 const projectRef = resolveOnboardingLandingProject(
1286 lastImportSelectionRef.current,
1287 projectsWithImportedHistoryRef.current,
1288 importedProjectsRef.current,
1289 );
1290 if (projectRef === undefined) {
1291 void onDone(undefined, importWarningRef.current, importedThreadCountRef.current);
1292 return;
1293 }
1294 setIsImporting(true);
1295 setLandingProject(projectRef);
1296 };
1297
1298 const runImport = async (selection: typeof candidates) => {
1299 if (isImporting) return;
1300 const hasAccess = () =>
1301 selection.every((candidate) =>
1302 readEnvironmentScope(candidate.environmentId, AuthOrchestrationOperateScope),
1303 );
1304 const stopForDeniedAccess = () => {
1305 setIsImporting(false);
1306 setImportError(IMPORT_PERMISSION_MESSAGE);
1307 };
1308 if (!hasAccess()) {
1309 stopForDeniedAccess();
1310 return;
1311 }
1312 if (selection.length === 0) {
1313 void onDone();
1314 return;
1315 }
1316 setIsImporting(true);
1317 importWarningRef.current = "";
1318 importedThreadCountRef.current = 0;
1319 lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
1320 const importGeneration = importGenerationRef.current;
1321 const importedProjects = importedProjectsRef.current;
1322 const projectAttempts = projectAttemptsRef.current;
1323 // Interrupted imports are neither failures nor successes — the command was
1324 // superseded or the environment dropped — but they still didn't land, so
1325 // they must not read as "imported everything". Retries skip paths that
1326 // already landed this session (re-creating them would only trip the
1327 // duplicate-root invariant and read as a failure).
1328 let importedProjectsCount =
1329 importedProjects.size > 0
1330 ? selection.filter((candidate) => importedProjects.has(candidate.key)).length
1331 : 0;
1332 let importedThreadCount = 0;
1333 let skippedThreadCount = 0;
1334 const refreshEnvironments = new Set<EnvironmentId>();
1335 for (const candidate of selection) {
1336 const { environmentId } = candidate;
1337 if (
1338 importGeneration !== importGenerationRef.current ||
1339 importedProjects !== importedProjectsRef.current
1340 ) {
1341 return;
1342 }
1343 if (!hasAccess()) {
1344 stopForDeniedAccess();
1345 return;
1346 }
1347 if (importedProjects.has(candidate.key)) continue;
1348 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
1349 if (projectId === null) {
1350 let attempt = projectAttempts.get(candidate.key);
1351 if (attempt === undefined) {
1352 const nextProjectId = newProjectId();
1353 attempt = {
1354 projectId: nextProjectId,
1355 commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),
1356 };
1357 projectAttempts.set(candidate.key, attempt);
1358 }
1359 projectId = attempt.projectId;
1360 const result = await createProject({
1361 environmentId,
1362 input: {
1363 projectId,
1364 commandId: attempt.commandId,
1365 title: candidate.title,
1366 workspaceRoot: candidate.path,
1367 createWorkspaceRootIfMissing: false,
1368 defaultModelSelection: null,
1369 },
1370 });
1371 if (
1372 importGeneration !== importGenerationRef.current ||
1373 importedProjects !== importedProjectsRef.current
1374 ) {
1375 return;
1376 }
1377 if (result._tag !== "Success") {
1378 if (!isAtomCommandInterrupted(result)) {
1379 projectAttempts.delete(candidate.key);
1380 refreshEnvironments.add(environmentId);
1381 }
1382 continue;
1383 }
1384 }
1385
1386 if (!hasAccess()) {
1387 stopForDeniedAccess();
1388 return;
1389 }
1390 const threadImportResult = await importThreads({
1391 environmentId,
1392 input: { projectId, expectedWorkspaceRoot: candidate.path },
1393 });
1394 if (
1395 importGeneration !== importGenerationRef.current ||
1396 importedProjects !== importedProjectsRef.current
1397 ) {
1398 return;
1399 }
1400 if (threadImportResult._tag === "Success") {
1401 importedThreadCount += threadImportResult.value.importedCount;
1402 skippedThreadCount += threadImportResult.value.skippedCount;
1403 if (threadImportResult.value.importedCount > 0) {
1404 projectsWithImportedHistoryRef.current.set(
1405 candidate.key,
1406 scopeProjectRef(environmentId, projectId),
1407 );
1408 }
1409 if (threadImportResult.value.skippedCount === 0) {
1410 importedProjectsCount += 1;
1411 importedProjects.set(candidate.key, scopeProjectRef(environmentId, projectId));
1412 }
1413 } else if (!isAtomCommandInterrupted(threadImportResult)) {
1414 projectAttempts.delete(candidate.key);
1415 refreshEnvironments.add(environmentId);
1416 }
1417 }
1418 for (const scan of scans) {
1419 if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
1420 }
1421 setIsImporting(false);
1422 importedThreadCountRef.current = importedThreadCount;
1423 if (importedProjectsCount < selection.length) {
1424 if (importedThreadCount > 0 && skippedThreadCount > 0) {
1425 importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`;
1426 } else if (skippedThreadCount > 0) {
1427 importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`;
1428 } else if (importedThreadCount > 0) {
1429 importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`;
1430 } else {
1431 importWarningRef.current = "Could not import thread history.";
1432 }
1433 }
1434 finishAfterImport();
1435 };
1436
1437 if (scans.every((scan) => scan.data === null) && scans.some((scan) => scan.isPending)) {
1438 return (
1439 <div className="flex h-full min-h-40 flex-col">
1440 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Your projects</h1>
1441 <div className="flex flex-1 flex-col items-center justify-center gap-3 py-6">
1442 <Spinner size="lg" tone="muted" />
1443 <p className="text-center text-sm text-muted-foreground">
1444 Looking for projects from Claude Code and Codex…
1445 </p>
1446 </div>
1447 <div className="flex justify-end">
1448 <Button variant="ghost-muted" onClick={() => void onDone()}>
1449 Do not import projects
1450 </Button>
1451 </div>
1452 </div>
1453 );
1454 }
1455
1456 return (
1457 <StepShell
1458 title="Import your projects"
1459 description="Import projects and conversations from Claude Code and Codex on your selected computers."
1460 >
1461 {candidates.length > 0 ? (
1462 <div className="mt-5 flex items-center justify-between gap-3 text-xs text-muted-foreground">
1463 <span role="status">
1464 {selected.length} of {candidates.length} selected
1465 </span>
1466 <div className="flex items-center gap-1">
1467 <Button
1468 variant="ghost"
1469 size="xs"
1470 disabled={isImporting || selected.length === candidates.length}
1471 onClick={() => setSelectedPaths(new Set(candidates.map((item) => item.key)))}
1472 >
1473 Select all
1474 </Button>
1475 <Button
1476 variant="ghost"
1477 size="xs"
1478 disabled={isImporting || selected.length === 0}
1479 onClick={() => setSelectedPaths(new Set())}
1480 >
1481 Select none
1482 </Button>
1483 </div>
1484 </div>
1485 ) : null}
1486 <ScrollArea scrollFade className="mt-2 h-auto max-h-80">
1487 <div className="space-y-5 pr-3">
1488 {scans.map((scan) => {
1489 const scanCandidates = candidates.filter(
1490 (candidate) => candidate.environmentId === scan.environmentId,
1491 );
1492 const label =
1493 environments.find((environment) => environment.environmentId === scan.environmentId)
1494 ?.label ?? "Computer";
1495 return (
1496 <fieldset
1497 key={scan.environmentId}
1498 className="min-w-0 space-y-0.5"
1499 disabled={isImporting}
1500 >
1501 {scans.length > 1 ? (
1502 <legend className="mb-2 text-sm font-medium">{label}</legend>
1503 ) : null}
1504 {scan.isPending && scan.data === null ? (
1505 <div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
1506 <Spinner size="md" />
1507 Looking for projects…
1508 </div>
1509 ) : scan.error !== null ? (
1510 <div
1511 role="alert"
1512 className="flex items-center justify-between gap-3 text-sm text-muted-foreground"
1513 >
1514 <span>Could not check projects. {scan.error}</span>
1515 <Button variant="ghost" size="sm" onClick={scan.refresh}>
1516 Retry
1517 </Button>
1518 </div>
1519 ) : scanCandidates.length === 0 ? (
1520 <p className="py-2 text-sm text-muted-foreground">
1521 No existing Claude Code or Codex projects found.
1522 </p>
1523 ) : null}
1524 {scan.data?.truncated ? (
1525 <p className="text-xs text-muted-foreground" role="status">
1526 {SCAN_LIMIT_MESSAGE}
1527 </p>
1528 ) : null}
1529 <ImportCandidateList
1530 candidates={scanCandidates}
1531 selectedKeys={selectedKeys}
1532 onSelectionChange={setSelectedPaths}
1533 />
1534 </fieldset>
1535 );
1536 })}
1537 </div>
1538 </ScrollArea>
1539 {visibleImportError ? (
1540 <p className="mt-3 text-sm text-destructive">{visibleImportError}</p>
1541 ) : null}
1542 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">
1543 <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}>
1544 Do not import projects
1545 </Button>
1546 <Button
1547 autoFocus
1548 disabled={!canImport || isImporting || selected.length === 0}
1549 onClick={() => void runImport(selected)}
1550 >
1551 {isImporting
1552 ? "Importing…"
1553 : `Import ${selected.length} ${selected.length === 1 ? "project" : "projects"}`}
1554 </Button>
1555 </div>
1556 </StepShell>
1557 );
1558}
1559
1560type ImportCandidate = AgentSessionProjectCandidate & {
1561 readonly environmentId: EnvironmentId;
1562 readonly key: string;
1563};
1564
1565/**
1566 * Repositories first, newest activity on top. Clones of one repository share
1567 * a group with a tri-state checkbox. Folders that are not git repositories
1568 * sit collapsed at the bottom so they stay reachable without adding noise.
1569 * Source icons appear only on repository rows so the columns stay still.
1570 */
1571function ImportCandidateList({
1572 candidates,
1573 selectedKeys,
1574 onSelectionChange,
1575}: {
1576 readonly candidates: ReadonlyArray<ImportCandidate>;
1577 readonly selectedKeys: ReadonlySet<string>;
1578 readonly onSelectionChange: (next: ReadonlySet<string>) => void;
1579}) {
1580 const { repositories, other } = useMemo(() => groupOnboardingProjects(candidates), [candidates]);
1581 const setKeys = (keys: ReadonlyArray<string>, checked: boolean) => {
1582 const next = new Set(selectedKeys);
1583 for (const key of keys) {
1584 if (checked) next.add(key);
1585 else next.delete(key);
1586 }
1587 onSelectionChange(next);
1588 };
1589 const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length;
1590
1591 return (
1592 <>
1593 {repositories.map((group) => (
1594 <ImportRepositoryGroup
1595 key={group.key}
1596 group={group}
1597 selectedKeys={selectedKeys}
1598 onToggle={setKeys}
1599 />
1600 ))}
1601 {other.length > 0 ? (
1602 <Collapsible>
1603 <div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40">
1604 <Checkbox
1605 checked={otherSelected === other.length}
1606 indeterminate={otherSelected > 0 && otherSelected < other.length}
1607 onCheckedChange={(checked) =>
1608 setKeys(
1609 other.map((candidate) => candidate.key),
1610 checked === true,
1611 )
1612 }
1613 />
1614 <CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-1.5 text-left">
1615 <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
1616 <span className="truncate text-sm text-muted-foreground">Other folders</span>
1617 <span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
1618 {other.length} {other.length === 1 ? "folder" : "folders"}
1619 </span>
1620 </CollapsibleTrigger>
1621 </div>
1622 <CollapsiblePanel>
1623 {other.map((candidate) => (
1624 <ImportCandidateRow
1625 key={candidate.key}
1626 candidate={candidate}
1627 path={candidate.path}
1628 nested
1629 checked={selectedKeys.has(candidate.key)}
1630 onCheckedChange={(checked) => setKeys([candidate.key], checked)}
1631 />
1632 ))}
1633 </CollapsiblePanel>
1634 </Collapsible>
1635 ) : null}
1636 </>
1637 );
1638}
1639
1640function ImportRepositoryGroup({
1641 group,
1642 selectedKeys,
1643 onToggle,
1644}: {
1645 readonly group: OnboardingProjectGroup<ImportCandidate>;
1646 readonly selectedKeys: ReadonlySet<string>;
1647 readonly onToggle: (keys: ReadonlyArray<string>, checked: boolean) => void;
1648}) {
1649 const keys = group.candidates.map((candidate) => candidate.key);
1650 const selectedCount = keys.filter((key) => selectedKeys.has(key)).length;
1651 const single = group.candidates.length === 1;
1652 const only = group.candidates[0];
1653 if (single && only !== undefined) {
1654 return (
1655 <ImportCandidateRow
1656 candidate={only}
1657 label={group.label}
1658 path={only.path}
1659 checked={selectedKeys.has(only.key)}
1660 onCheckedChange={(checked) => onToggle([only.key], checked)}
1661 />
1662 );
1663 }
1664 return (
1665 <Collapsible defaultOpen>
1666 <div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40">
1667 <Checkbox
1668 checked={selectedCount === keys.length}
1669 indeterminate={selectedCount > 0 && selectedCount < keys.length}
1670 onCheckedChange={(checked) => onToggle(keys, checked === true)}
1671 />
1672 <CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-1.5 text-left">
1673 <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
1674 <span className="truncate text-sm font-medium">{group.label}</span>
1675 <ImportRowMeta
1676 sources={[...new Set(group.candidates.flatMap((c) => c.sources))]}
1677 threadCount={group.threadCount}
1678 lastActiveAt={group.lastActiveAt}
1679 />
1680 </CollapsibleTrigger>
1681 </div>
1682 <CollapsiblePanel>
1683 {group.candidates.map((candidate) => (
1684 <ImportCandidateRow
1685 key={candidate.key}
1686 candidate={candidate}
1687 path={candidate.path}
1688 nested
1689 checked={selectedKeys.has(candidate.key)}
1690 onCheckedChange={(checked) => onToggle([candidate.key], checked)}
1691 />
1692 ))}
1693 </CollapsiblePanel>
1694 </Collapsible>
1695 );
1696}
1697
1698/**
1699 * Clones of one repository and plain folders differ only by folder name, so
1700 * the name stays visible and the shared parent path truncates first.
1701 */
1702function ProjectPathText({
1703 path,
1704 className,
1705}: {
1706 readonly path: string;
1707 readonly className?: string;
1708}) {
1709 const { name, parent } = splitOnboardingProjectPath(path);
1710 return (
1711 <span className={cn("flex min-w-0 items-baseline font-mono", className)}>
1712 {parent.length > 0 ? <span className="truncate">{parent}</span> : null}
1713 <span className="max-w-full shrink-0 truncate">{name}</span>
1714 </span>
1715 );
1716}
1717
1718function ImportCandidateRow({
1719 candidate,
1720 label,
1721 path,
1722 nested = false,
1723 checked,
1724 onCheckedChange,
1725}: {
1726 readonly candidate: ImportCandidate;
1727 /** Text primary label. Nested rows omit it and are labelled by their path. */
1728 readonly label?: string;
1729 /** Shown with the folder name kept visible while the parent path truncates. */
1730 readonly path?: string;
1731 readonly nested?: boolean;
1732 readonly checked: boolean;
1733 readonly onCheckedChange: (checked: boolean) => void;
1734}) {
1735 return (
1736 <label
1737 className={cn(
1738 "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40 has-disabled:cursor-default",
1739 nested && "pl-8",
1740 )}
1741 >
1742 <Checkbox checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} />
1743 <Tooltip>
1744 <TooltipTrigger
1745 render={<span className="flex min-w-0 flex-1 items-baseline gap-2 truncate" />}
1746 >
1747 {label !== undefined ? (
1748 <span className="truncate text-sm font-medium">{label}</span>
1749 ) : null}
1750 {path !== undefined ? (
1751 <ProjectPathText
1752 path={path}
1753 className={label === undefined ? "text-xs" : "text-2xs text-muted-foreground"}
1754 />
1755 ) : null}
1756 </TooltipTrigger>
1757 <TooltipPopup variant="code">{candidate.path}</TooltipPopup>
1758 </Tooltip>
1759 <ImportRowMeta
1760 sources={nested ? null : candidate.sources}
1761 threadCount={candidate.threadCount}
1762 lastActiveAt={candidate.lastActiveAt}
1763 />
1764 </label>
1765 );
1766}
1767
1768/**
1769 * Trailing columns shared by every import row: source icons, thread count,
1770 * last activity. Each column has a fixed width and each icon has its own slot
1771 * so nothing shifts between rows that differ in sources or digit count.
1772 */
1773function ImportRowMeta({
1774 sources,
1775 threadCount,
1776 lastActiveAt,
1777}: {
1778 readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null;
1779 readonly threadCount: number;
1780 readonly lastActiveAt: string | null;
1781}) {
1782 const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt);
1783 // "just now" does not fit the fixed column, so collapse it.
1784 const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value;
1785 return (
1786 <span className="ml-auto grid shrink-0 grid-cols-[1rem_1rem_2.5rem_2.25rem] items-center gap-x-1 text-xs text-muted-foreground tabular-nums">
1787 <span className="flex size-4 items-center justify-center">
1788 {sources?.includes("claudeAgent") ? (
1789 <span role="img" aria-label="Claude Code">
1790 <ProviderInstanceIcon
1791 driverKind={ProviderDriverKind.make("claudeAgent")}
1792 displayName="Claude Code"
1793 iconClassName="size-3"
1794 />
1795 </span>
1796 ) : null}
1797 </span>
1798 <span className="flex size-4 items-center justify-center">
1799 {sources?.includes("codex") ? (
1800 <span role="img" aria-label="Codex">
1801 <ProviderInstanceIcon
1802 driverKind={ProviderDriverKind.make("codex")}
1803 displayName="Codex"
1804 iconClassName="size-3"
1805 />
1806 </span>
1807 ) : null}
1808 </span>
1809 <span className="text-right">{threadCount}</span>
1810 <span className="text-right whitespace-nowrap">{age}</span>
1811 </span>
1812 );
1813}
1814
1815// ── Shared bits ──────────────────────────────────────────────
1816
1817function StepShell({
1818 title,
1819 description,
1820 children,
1821}: {
1822 readonly title: string;
1823 readonly description?: string;
1824 readonly children?: React.ReactNode;
1825}) {
1826 return (
1827 <>
1828 <h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
1829 {description ? (
1830 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
1831 ) : null}
1832 {children}
1833 </>
1834 );
1835}