style(web): refine onboarding menus - #5371
Conversation
New onboarding flow for fresh installs: choose how to connect (Local Only / T3 Connect / Direct), verify Claude Code and Codex with live probe status and an inline install terminal, then import existing projects discovered from Claude/Codex home directories. - FirstRunGate at the root holds back the entire authenticated tree until the first-run decision is known, so fresh installs see nothing before /welcome (no shell flash, no EventRouter thread navigation) - New read-only agentSessions.scan RPC discovers project candidates from ~/.claude/projects and Codex session rollouts (cwd from transcript first lines, never the lossy dir slug; T3-managed worktrees excluded) - Import creates projects via existing project.create dispatch; default window is last 30 days, full checklist behind Choose - onboardingCompletedAt client setting gates the wizard; installs that predate the field only qualify when the workspace is fresh Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Scanner: expand ~ in CLAUDE_CONFIG_DIR; sort transcripts by mtime before applying the per-source cap; check alreadyImported against both recorded and realpath spellings; derive the worktree-sandbox filter from the configured worktreesDir instead of hard-coding .t3 - Contracts: AgentSessionScanError carries the structural operation discriminator and derives its message (drops redundant failure literal) - FirstRunGate: require server config before judging workspace freshness (fixes both the offline-install-sees-wizard and config-race cases); only gate authenticated sessions - Wizard: T3 Connect "connected" view requires a live connection; the no-machine wait gets a Skip; partial import failures keep the step open and report the count; disabled providers show "Enable in Settings" instead of Ready/Install; failed terminal opens retry - Docs: match actual first-run and terminal sign-in behavior Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l reattach Windows resolves paths with backslashes, so the configured-worktreesDir prefix match failed there; both sides now normalize to forward slashes. Reopening the onboarding install terminal reattaches to the still-live PTY, so the pre-typed command is only written into a session with empty history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A symlink whose spelling looks harmless can resolve into the worktrees directory, so the filter now also runs against the realpath. Windows comparisons case fold, using the injectable HostProcessPlatform reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sharing one terminal id between install and sign-in meant reattach either skipped the login pre-type (history guard) or duplicated the command (without it). Each drawer mount now opens a uniquely-id'd session that Done closes, so pre-typing is always into a brand-new PTY. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nvironment The server-local error was a field-for-field clone of the contract AgentSessionScanError, so the ws.ts mapping was an identity re-tag; the service now fails with the contract error directly. CLAUDE_CONFIG_DIR reads through the injectable HostProcessEnvironment reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continue/Skip, switching agent cards, and session exit all unmount the drawer without hitting Done, which left uniquely-id'd PTYs running behind the wizard. Cleanup now lives in the unmount effect so every exit path kills the session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A silently failed write left a blank prompt under copy telling the user to press Enter; the header now falls back to showing the command to run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or cause The read cap now has a companion stat budget so a pathological Claude home can't trigger unbounded sequential stats. Claude project directories group transcripts by recorded cwd like the Codex scan — the slug is lossy, so one directory can hold sessions from several distinct paths. Contract error cause becomes required since every construction wraps a real failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| : (AGENT_INSTALL_COMMANDS[driver] ?? ""); | ||
| const [preTypeFailed, setPreTypeFailed] = useState(false); | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
🟡 Medium onboarding/WelcomeWizard.tsx:668
When openTerminal fails, AgentInstallTerminal never retries. The async task returns without changing any state or ref, and because the useEffect dependencies don't change, React has no reason to re-run the effect on a subsequent render — so a transient RPC failure leaves the inline terminal permanently unattached despite the inline comment claiming it retries on the next render. The user must close and reopen the card to recover. Consider tracking the attempt (e.g., with a retry counter in state) so a failed open triggers a re-render and re-invocation, or remove the retry claim if retries are not intended.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/onboarding/WelcomeWizard.tsx around line 668:
When `openTerminal` fails, `AgentInstallTerminal` never retries. The async task returns without changing any state or ref, and because the `useEffect` dependencies don't change, React has no reason to re-run the effect on a subsequent render — so a transient RPC failure leaves the inline terminal permanently unattached despite the inline comment claiming it retries on the next render. The user must close and reopen the card to recover. Consider tracking the attempt (e.g., with a retry counter in state) so a failed open triggers a re-render and re-invocation, or remove the retry claim if retries are not intended.
| code (`npx t3 connect`) and advances when it appears. | ||
| - **Direct** — connect to a server by URL. Works over LAN and Tailscale. Run |
There was a problem hiding this comment.
🟢 Low user/welcome-wizard.md:13
The docs say the wizard "advances when" a connected machine appears, but the user must press Continue after the machine shows up. Users will wait for an automatic transition that never happens. Update the sentence to mention the required Continue action.
+- connected yet, the wizard shows the command to run on the machine with your
+- code (`npx t3 connect`) and advances when it appears.
++- connected yet, the wizard shows the command to run on the machine with your
++- code (`npx t3 connect`). When the machine appears in the list, press
++- **Continue** to proceed.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/user/welcome-wizard.md around lines 13-14:
The docs say the wizard "advances when" a connected machine appears, but the user must press **Continue** after the machine shows up. Users will wait for an automatic transition that never happens. Update the sentence to mention the required **Continue** action.
| ) : disabled ? ( | ||
| <span className="text-xs text-muted-foreground">Enable in Settings</span> | ||
| ) : ( | ||
| <Button size="xs" variant="outline" onClick={onOpenTerminal} disabled={terminalOpen}> | ||
| <TerminalIcon className="size-3.5" /> | ||
| {needsLogin ? "Sign in" : "Install"} | ||
| </Button> | ||
| )} |
There was a problem hiding this comment.
🟡 Medium onboarding/WelcomeWizard.tsx:610
The AgentCard shows an enabled "Install" button whenever provider is undefined, which is the normal state before the provider probe completes (the summary headline reads "Checking provider status"). A user who clicks during this window opens AgentInstallTerminal with installed=false, so it pre-types the global install command (npm install -g ...) even when the CLI is already installed and only needs a login. The action should be disabled while provider status is unknown, or the card should distinguish "unknown" from "not installed".
| ) : disabled ? ( | |
| <span className="text-xs text-muted-foreground">Enable in Settings</span> | |
| ) : ( | |
| <Button size="xs" variant="outline" onClick={onOpenTerminal} disabled={terminalOpen}> | |
| <TerminalIcon className="size-3.5" /> | |
| {needsLogin ? "Sign in" : "Install"} | |
| </Button> | |
| )} | |
| ) : disabled ? ( | |
| <span className="text-xs text-muted-foreground">Enable in Settings</span> | |
| ) : provider === undefined ? ( | |
| <Button size="xs" variant="outline" disabled> | |
| <TerminalIcon className="size-3.5" /> | |
| Checking… | |
| </Button> | |
| ) : ( |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/onboarding/WelcomeWizard.tsx around lines 610-617:
The `AgentCard` shows an enabled "Install" button whenever `provider` is `undefined`, which is the normal state before the provider probe completes (the summary headline reads "Checking provider status"). A user who clicks during this window opens `AgentInstallTerminal` with `installed=false`, so it pre-types the global install command (`npm install -g ...`) even when the CLI is already installed and only needs a login. The action should be disabled while provider status is unknown, or the card should distinguish "unknown" from "not installed".
| useEffect(() => { | ||
| if (decision !== "pending") return; | ||
| const timer = window.setTimeout(() => setDecision("app"), FIRST_RUN_DECISION_TIMEOUT_MS); | ||
| return () => window.clearTimeout(timer); | ||
| }, [decision]); |
There was a problem hiding this comment.
🟡 Medium onboarding/FirstRunGate.tsx:90
The fallback timer fires even while client settings are still unhydrated, so a slow hydration (>4s) permanently sets decision to "app" before the workspace-freshness check ever runs. On a genuinely fresh install this skips the welcome wizard purely because settings hydration was slow, even when the server and shells subsequently load successfully. The second useEffect guards the "app" transition on hydrated, but the timeout effect does not, so it resolves the decision out from under it. Start the timer only after hydrated is true so it cannot fire during the hydration window.
useEffect(() => {
- if (decision !== "pending") return;
+ if (decision !== "pending" || !hydrated) return;
const timer = window.setTimeout(() => setDecision("app"), FIRST_RUN_DECISION_TIMEOUT_MS);
return () => window.clearTimeout(timer);
- }, [decision]);
+ }, [decision, hydrated]);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/onboarding/FirstRunGate.tsx around lines 90-94:
The fallback timer fires even while client settings are still unhydrated, so a slow hydration (>4s) permanently sets `decision` to `"app"` before the workspace-freshness check ever runs. On a genuinely fresh install this skips the welcome wizard purely because settings hydration was slow, even when the server and shells subsequently load successfully. The second `useEffect` guards the `"app"` transition on `hydrated`, but the timeout effect does not, so it resolves the decision out from under it. Start the timer only after `hydrated` is true so it cannot fire during the hydration window.
| connected yet, the wizard shows the command to run on the machine with your | ||
| code (`npx t3 connect`) and advances when it appears. | ||
| - **Direct** — connect to a server by URL. Works over LAN and Tailscale. Run | ||
| `npx t3 pair` on the server and paste the pairing URL it prints. |
There was a problem hiding this comment.
🟢 Low user/welcome-wizard.md:15
The Direct instructions tell users to run npx t3 pair to connect over Tailscale, but plain npx t3 pair produces a non-Tailscale endpoint. The correct command for Tailscale is npx t3 pair --tailscale; without the flag, remote Tailscale users get an unreachable pairing URL.
| `npx t3 pair` on the server and paste the pairing URL it prints. | |
| + `npx t3 pair --tailscale` on the server and paste the pairing URL it prints. |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/user/welcome-wizard.md around line 15:
The Direct instructions tell users to run `npx t3 pair` to connect over Tailscale, but plain `npx t3 pair` produces a non-Tailscale endpoint. The correct command for Tailscale is `npx t3 pair --tailscale`; without the flag, remote Tailscale users get an unreachable pairing URL.
| return caseFold ? normalized.toLowerCase() : normalized; | ||
| } | ||
|
|
||
| function isT3ManagedWorktree( |
There was a problem hiding this comment.
🟡 Medium project/AgentSessionScanner.ts:102
isT3ManagedWorktree compares the realpath-resolved candidate path against the unresolved spelling of worktreesDir. When worktreesDir is reached through a symlink, the candidate's realpath points at the physical target, which won't share the prefix of the un-resolved worktreesDir spelling. If that target also doesn't contain /.t3/worktrees/, the filter fails and the app's own disposable worktree is incorrectly returned as an import candidate. Canonicalize worktreesDir via realPath before the prefix comparison.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/project/AgentSessionScanner.ts around line 102:
`isT3ManagedWorktree` compares the realpath-resolved candidate path against the unresolved spelling of `worktreesDir`. When `worktreesDir` is reached through a symlink, the candidate's realpath points at the physical target, which won't share the prefix of the un-resolved `worktreesDir` spelling. If that target also doesn't contain `/.t3/worktrees/`, the filter fails and the app's own disposable worktree is incorrectly returned as an import candidate. Canonicalize `worktreesDir` via `realPath` before the prefix comparison.
| */ | ||
| const MAX_TRANSCRIPTS_PER_SOURCE = 5000; | ||
|
|
||
| /** |
There was a problem hiding this comment.
🟡 Medium project/AgentSessionScanner.ts:53
MAX_STATS_PER_SOURCE is consumed in scanClaude while iterating projectDirectories in filesystem-enumeration order, not mtime order. A single early Claude project with enough transcripts exhausts the budget, so every later project is skipped regardless of recency. Even within that project, transcripts.slice(0, statBudget) can omit newer transcripts because readDirectory returns unsorted entries. The scan can therefore hide recent, importable projects instead of only dropping stale sessions as the cap's documentation states. Consider statting all transcripts across all project directories before applying the budget, or at least sorting project directories by their newest transcript mtime so the budget drops the oldest directories first.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/project/AgentSessionScanner.ts around line 53:
`MAX_STATS_PER_SOURCE` is consumed in `scanClaude` while iterating `projectDirectories` in filesystem-enumeration order, not mtime order. A single early Claude project with enough transcripts exhausts the budget, so every later project is skipped regardless of recency. Even within that project, `transcripts.slice(0, statBudget)` can omit newer transcripts because `readDirectory` returns unsorted entries. The scan can therefore hide recent, importable projects instead of only dropping stale sessions as the cap's documentation states. Consider statting all transcripts across all project directories before applying the budget, or at least sorting project directories by their newest transcript mtime so the budget drops the oldest directories first.
| defaultModelSelection, | ||
| }, | ||
| }); | ||
| if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
There was a problem hiding this comment.
🟡 Medium onboarding/WelcomeWizard.tsx:806
runImport treats interrupted createProject results as success: when result._tag === "Failure" and isAtomCommandInterrupted(result) is true, the project is not created, but failures is not incremented. After the loop, failures can be zero and onDone() fires, marking onboarding complete even though selected projects were never created. The interrupt branch falls through the loop alongside successful results. Consider detecting interrupted results and bailing out (or counting them as failures) so a disconnection during import doesn't silently advance the wizard.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/onboarding/WelcomeWizard.tsx around line 806:
`runImport` treats interrupted `createProject` results as success: when `result._tag === "Failure"` and `isAtomCommandInterrupted(result)` is true, the project is not created, but `failures` is not incremented. After the loop, `failures` can be zero and `onDone()` fires, marking onboarding complete even though selected projects were never created. The interrupt branch falls through the loop alongside successful results. Consider detecting interrupted results and bailing out (or counting them as failures) so a disconnection during import doesn't silently advance the wizard.
There was a problem hiding this comment.
Effect service conventions review of the new AgentSessionScanner service, its contract error, and the ws wiring. One finding on error context; everything else (namespace imports, Context.Service + inline interface, make/layer order and naming, Foo["Service"] references, environment-based dependency acquisition, no hidden runtimes) matches the conventions.
Posted via Macroscope — Effect Service Conventions
| Effect.mapError( | ||
| (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), | ||
| ), |
There was a problem hiding this comment.
AgentSessionScanError carries only operation + cause, but the resource that failed is known right here (lookupPath). Consider capturing it so the error identifies the resource structurally, as sibling services in this directory do (ProjectSetupScriptOperationError keeps projectCwd/worktreePath, the favicon resolver error keeps workspaceRoot).
In packages/contracts/src/agentSessions.ts:
operation: Schema.Literals(["read-settings", "read-projects"]),
+ workspaceRoot: Schema.optional(TrimmedNonEmptyString),
cause: Schema.Defect(),and at this call site:
- (cause) => new AgentSessionScanError({ operation: "read-projects", cause }),
+ (cause) =>
+ new AgentSessionScanError({
+ operation: "read-projects",
+ workspaceRoot: lookupPath,
+ cause,
+ }),The message getter can then include the path when present, still derived purely from structural attributes.
Posted via Macroscope — Effect Service Conventions
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1457866. Configure here.
|
|
||
| const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (filePath: string) { | ||
| const line = yield* readFirstLine(filePath); | ||
| return line === null ? null : extractCwd(line.trim()); |
There was a problem hiding this comment.
Claude cwd read skips sessions
High Severity
readCwd only inspects the first JSONL line, but Claude Code transcripts often start with records like file-history-snapshot that have no cwd. Those sessions are dropped even when a later line in the already-read prefix has the path, so the import step undercounts or misses Claude projects.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1457866. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This change introduces a substantial first-run onboarding workflow with new routing, pairing and terminal interactions, filesystem scanning, project creation, and an auth-protected RPC rather than merely refining menu styling. Unresolved correctness findings also affect scanning, importing, worktree exclusion, and onboarding state transitions. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
| } | ||
|
|
||
| if (choosing) { | ||
| const selectedCount = candidates.length - deselected.size; |
There was a problem hiding this comment.
🟠 High onboarding/WelcomeWizard.tsx:858
selectedCount subtracts the entire persistent deselected set size from candidates.length instead of counting how many current candidates are in deselected. When the onboarding target environment changes (e.g. the current machine disconnects and another connected environment becomes the target), stale paths from the old environment remain in deselected even though they are not in the new candidates. This makes selectedCount subtract entries that are no longer present, so the count can drop to zero or go negative while every checkbox still appears checked — disabling the Import button despite all candidates being selected. Consider computing the count by filtering current candidates against deselected, or resetting deselected when candidates changes.
| const selectedCount = candidates.length - deselected.size; | |
| const selectedCount = candidates.filter((candidate) => !deselected.has(candidate.path)).length; |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/onboarding/WelcomeWizard.tsx around line 858:
`selectedCount` subtracts the entire persistent `deselected` set size from `candidates.length` instead of counting how many current candidates are in `deselected`. When the onboarding target environment changes (e.g. the current machine disconnects and another connected environment becomes the target), stale paths from the old environment remain in `deselected` even though they are not in the new `candidates`. This makes `selectedCount` subtract entries that are no longer present, so the count can drop to zero or go negative while every checkbox still appears checked — disabling the Import button despite all candidates being selected. Consider computing the count by filtering current candidates against `deselected`, or resetting `deselected` when `candidates` changes.
92c434a to
0b680a7
Compare
9c93f3f to
612c2d3
Compare
d02d817 to
8468b95
Compare
5047c7b to
50f2b7f
Compare
5c1b667 to
a0c0a2b
Compare
|
Superseded by #10465, which rewrote onboarding into a shared multi-computer wizard (same FirstRunGate / WelcomeWizard / welcome route surfaces this PR was styling). Closing as superseded. |


summary
verification
vp run --filter @t3tools/web typecheckpnpm --dir apps/web test(204 files, 1777 tests)/welcomeworkflow in light and dark modesscreenshots
before, light
after, light
before, dark
after, dark
Note
Medium Risk
Touches first-load routing (blank screen until gate resolves) and new filesystem reads of agent home directories, though the scan is read-only and gated behind existing orchestration read authorization.
Overview
Adds a first-run onboarding path for fresh installs:
FirstRunGateblocks the authenticated shell until client settings and workspace state are known, then sends empty workspaces to a full-screen/welcomewizard and records completion viaonboardingCompletedAt.The wizard walks through connection choice (local / T3 Connect / direct pair), optional machine pairing, Claude Code & Codex setup with an inline PTY for install/login, and a project import step that calls a new server scan. Onboarding UI uses an even-width connection card row, softer pairing copy, and a single subdued surface for the import checklist instead of per-row borders.
On the server,
AgentSessionScannerreads Claude and Codex transcript prefixes to infercwds, merges and sorts candidates, skips missing dirs and T3 worktree sandboxes (including symlink targets), marks paths already imported, and exposes results throughagentSessions.scan(orchestration read scope) with contract types and web query wiring.Reviewed by Cursor Bugbot for commit 05f1086. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add multi-step onboarding wizard and agent-session project scanner
FirstRunGatein __root.tsx that withholds the app tree for authenticated sessions until onboarding state resolves, routing fresh workspaces to/welcomeand timing out to the app after 4 seconds.WelcomeWizardflow in WelcomeWizard.tsx with steps for connection selection, T3 Connect machine setup, direct server pairing, agent install/login via inline terminals, and project import.AgentSessionScannerservice in AgentSessionScanner.ts that reads Claude and Codex transcript directories, groups candidates by workspace cwd, excludes T3-managed worktrees, marks already-imported projects, and returns newest-first results within bounded read/stat budgets.agentSessions.scanWebSocket RPC andAgentSessionProjectCandidate/AgentSessionScanResultcontracts in agentSessions.ts, plus a nullableonboardingCompletedAtfield on client settings.onboardingCompletedAttimestamp and a fresh workspace will now see the wizard instead of the app on first load; the gate falls back to the app afterFIRST_RUN_DECISION_TIMEOUT_MS(4 s) if onboarding checks remain unresolved.Macroscope summarized 05f1086.