| 1 | import { RemoteConnectionFlow } from "../settings/SelfHostedServer"; |
| 2 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; |
| 3 | import { useAuth } from "@clerk/react"; |
| 4 | import { useAtomValue } from "@effect/atom-react"; |
| 5 | import type { |
| 6 | AgentSessionProjectCandidate, |
| 7 | EnvironmentId, |
| 8 | ProjectId, |
| 9 | ProviderInstanceId, |
| 10 | ScopedProjectRef, |
| 11 | ServerConfig, |
| 12 | ServerProvider, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 15 | import { |
| 16 | isAtomCommandInterrupted, |
| 17 | squashAtomCommandFailure, |
| 18 | } from "@t3tools/client-runtime/state/runtime"; |
| 19 | import { |
| 20 | AuthTerminalOperateScope, |
| 21 | CommandId, |
| 22 | defaultInstanceIdForDriver, |
| 23 | AuthOrchestrationOperateScope, |
| 24 | ProviderDriverKind, |
| 25 | ThreadId, |
| 26 | } from "@t3tools/contracts"; |
| 27 | import * as Schema from "effect/Schema"; |
| 28 | import { |
| 29 | ArrowRightIcon, |
| 30 | CheckIcon, |
| 31 | ChevronRightIcon, |
| 32 | CloudIcon, |
| 33 | MonitorIcon, |
| 34 | TerminalIcon, |
| 35 | } from "lucide-react"; |
| 36 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 37 | |
| 38 | import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; |
| 39 | import { useLocalStorage } from "../../hooks/useLocalStorage"; |
| 40 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 41 | import { PRIVACY_POLICY_URL } from "../../legalLinks"; |
| 42 | import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; |
| 43 | import { useCompleteOnboarding } from "../../onboarding/firstRun"; |
| 44 | import { |
| 45 | groupOnboardingProjects, |
| 46 | partitionOnboardingProjects, |
| 47 | onboardingProjectKey, |
| 48 | resolveOnboardingLandingProject, |
| 49 | resolveOnboardingProjectId, |
| 50 | type OnboardingProjectGroup, |
| 51 | splitOnboardingProjectPath, |
| 52 | } from "../../onboarding/projectImport.logic"; |
| 53 | import { |
| 54 | getOnboardingProviderState, |
| 55 | resolveOnboardingProviderInstallCommand, |
| 56 | resolveOnboardingProviderLoginCommand, |
| 57 | selectOnboardingProvidersByDriver, |
| 58 | } from "../../onboarding/providerReadiness.logic"; |
| 59 | import { newProjectId, randomUUID } from "../../lib/utils"; |
| 60 | import { agentSessionImport } from "../../state/agentSessions"; |
| 61 | import { readProjects, useProjects } from "../../state/entities"; |
| 62 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; |
| 63 | import { |
| 64 | isOnboardingRelayEnvironment, |
| 65 | resolveOnboardingSetup, |
| 66 | } from "../../onboarding/targetEnvironment.logic"; |
| 67 | import { useProjectScans } from "../../onboarding/useProjectScans"; |
| 68 | import { projectEnvironment } from "../../state/projects"; |
| 69 | import { serverEnvironment } from "../../state/server"; |
| 70 | import { |
| 71 | readEnvironmentScope, |
| 72 | useEnvironmentScope, |
| 73 | useEnvironmentsWithScope, |
| 74 | } from "../../state/session"; |
| 75 | import { terminalEnvironment } from "../../state/terminal"; |
| 76 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 77 | import { connectPairing } from "../../connection/onboarding"; |
| 78 | import { getProviderSummary } from "../settings/providerStatus"; |
| 79 | import { providerClients } from "../settings/providerDriverMeta"; |
| 80 | import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; |
| 81 | import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; |
| 82 | import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; |
| 83 | import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; |
| 84 | import { TerminalViewport } from "../ThreadTerminalDrawer"; |
| 85 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 86 | import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation"; |
| 87 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 88 | import { KnifeMark } from "../KnifeMark"; |
| 89 | import { Alert, AlertDescription } from "../ui/alert"; |
| 90 | import { Button } from "../ui/button"; |
| 91 | import { CommandBlock } from "../CommandBlock"; |
| 92 | import { Checkbox } from "../ui/checkbox"; |
| 93 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 94 | import { Input } from "../ui/input"; |
| 95 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 96 | import { ScrollArea } from "../ui/scroll-area"; |
| 97 | import { Spinner } from "../ui/spinner"; |
| 98 | import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard"; |
| 99 | import { Dialog } from "../ui/dialog"; |
| 100 | import { toastManager } from "../ui/toast"; |
| 101 | import { cn } from "../../lib/utils"; |
| 102 | import { 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 | |
| 113 | type WizardStep = "connection" | "agents" | "import"; |
| 114 | const NO_ENVIRONMENTS: readonly EnvironmentId[] = []; |
| 115 | |
| 116 | const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); |
| 117 | const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; |
| 118 | const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; |
| 119 | |
| 120 | export 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 | |
| 298 | function 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 | |
| 460 | function 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 | */ |
| 545 | function 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 | |
| 657 | const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const; |
| 658 | type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; |
| 659 | |
| 660 | /** Setup values stay fixed while provider probes refresh the surrounding cards. */ |
| 661 | interface 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. */ |
| 671 | function 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 | |
| 708 | function 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 | |
| 873 | function 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 | |
| 958 | function 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 | */ |
| 1025 | function 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 | |
| 1194 | const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history."; |
| 1195 | |
| 1196 | function 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 | |
| 1560 | type 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 | */ |
| 1571 | function 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 | |
| 1640 | function 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 | */ |
| 1702 | function 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 | |
| 1718 | function 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 | */ |
| 1773 | function 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 | |
| 1817 | function 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 | } |