authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 10:21:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 10:21:24-07:00
logdb46c42744874ca123bc170a90c1faa312f20119
tree6396142c4db1337046781a50dd07af9ce0e253bc
parent87c366b1206b13701fbcc38e459d457f0f9da2df
signature Signed by PGP key B5690EEEBB952194

fix(web): onboarding Continue no longer locks on computers that won't connect (#16887)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 100 insertions(+), 17 deletions(-)

apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx+1-1
...@@ -64,7 +64,7 @@ vi.mock("../../state/environments", () => {...@@ -64,7 +64,7 @@ vi.mock("../../state/environments", () => {
64 environmentId,64 environmentId,
65 label: environmentId,65 label: environmentId,
66 connection: { phase: "connected" },66 connection: { phase: "connected" },
67 entry: { target: { _tag: "DirectConnectionTarget" } },67 entry: { enabled: true, target: { _tag: "DirectConnectionTarget" } },
68 });68 });
69 const primary = environment("primary");69 const primary = environment("primary");
70 return {70 return {
apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx+2
...@@ -151,6 +151,7 @@ const primaryEnvironment = {...@@ -151,6 +151,7 @@ const primaryEnvironment = {
151 label: "This computer",151 label: "This computer",
152 connection: { phase: "connected" },152 connection: { phase: "connected" },
153 entry: {153 entry: {
154 enabled: true,
154 target: new PrimaryConnectionTarget({155 target: new PrimaryConnectionTarget({
155 environmentId: primaryId,156 environmentId: primaryId,
156 label: "This computer",157 label: "This computer",
...@@ -164,6 +165,7 @@ const remoteEnvironment = {...@@ -164,6 +165,7 @@ const remoteEnvironment = {
164 label: "Paired computer",165 label: "Paired computer",
165 connection: { phase: "connected" },166 connection: { phase: "connected" },
166 entry: {167 entry: {
168 enabled: true,
167 target: new BearerConnectionTarget({169 target: new BearerConnectionTarget({
168 environmentId: remoteId,170 environmentId: remoteId,
169 label: "Paired computer",171 label: "Paired computer",
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+1
...@@ -38,6 +38,7 @@ vi.mock("../../state/environments", () => {...@@ -38,6 +38,7 @@ vi.mock("../../state/environments", () => {
38 environmentId: "test-env",38 environmentId: "test-env",
39 label: "Computer",39 label: "Computer",
40 connection: { phase: "connected" },40 connection: { phase: "connected" },
41 entry: { enabled: true },
41 };42 };
42 return {43 return {
43 useEnvironments: () => ({ environments: [environment] }),44 useEnvironments: () => ({ environments: [environment] }),
apps/web/src/components/onboarding/WelcomeWizard.tsx+21-16
...@@ -58,7 +58,10 @@ import { newProjectId, randomUUID } from "../../lib/utils";...@@ -58,7 +58,10 @@ import { newProjectId, randomUUID } from "../../lib/utils";
58import { agentSessionImport } from "../../state/agentSessions";58import { agentSessionImport } from "../../state/agentSessions";
59import { readProjects, useProjects } from "../../state/entities";59import { readProjects, useProjects } from "../../state/entities";
60import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";60import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
61import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic";61import {
62 isOnboardingRelayEnvironment,
63 resolveOnboardingSetup,
64} from "../../onboarding/targetEnvironment.logic";
62import { useProjectScans } from "../../onboarding/useProjectScans";65import { useProjectScans } from "../../onboarding/useProjectScans";
63import { projectEnvironment } from "../../state/projects";66import { projectEnvironment } from "../../state/projects";
64import { serverEnvironment } from "../../state/server";67import { serverEnvironment } from "../../state/server";
...@@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";...@@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";
78import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";81import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";
79import { TerminalViewport } from "../ThreadTerminalDrawer";82import { TerminalViewport } from "../ThreadTerminalDrawer";
80import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";83import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
84import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation";
81import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";85import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
82import { T3Wordmark } from "../T3Wordmark";86import { T3Wordmark } from "../T3Wordmark";
83import { Alert, AlertDescription } from "../ui/alert";87import { Alert, AlertDescription } from "../ui/alert";
...@@ -141,11 +145,14 @@ export function WelcomeWizard({...@@ -141,11 +145,14 @@ export function WelcomeWizard({
141 for (const environment of newComputers) {145 for (const environment of newComputers) {
142 autoSelectedComputers.current.add(environment.environmentId);146 autoSelectedComputers.current.add(environment.environmentId);
143 }147 }
148 // A computer the user switched off stays unselected until they pick it.
149 const enabledComputers = newComputers.filter((environment) => environment.entry.enabled);
150 if (enabledComputers.length === 0) return;
144 setSelection(151 setSelection(
145 (current) =>152 (current) =>
146 new Set([153 new Set([
147 ...(current ?? []),154 ...(current ?? []),
148 ...newComputers.map((environment) => environment.environmentId),155 ...enabledComputers.map((environment) => environment.environmentId),
149 ]),156 ]),
150 );157 );
151 }, [environments]);158 }, [environments]);
...@@ -262,11 +269,7 @@ export function WelcomeWizard({...@@ -262,11 +269,7 @@ export function WelcomeWizard({
262 })269 })
263 }270 }
264 onContinue={() =>271 onContinue={() =>
265 startSetup(272 startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds)
266 environments
267 .filter((environment) => selectedIds.has(environment.environmentId))
268 .map((environment) => environment.environmentId),
269 )
270 }273 }
271 onPaired={(environmentId) => {274 onPaired={(environmentId) => {
272 setSelection(new Set([...selectedIds, environmentId]));275 setSelection(new Set([...selectedIds, environmentId]));
...@@ -315,14 +318,10 @@ function ConnectionStep({...@@ -315,14 +318,10 @@ function ConnectionStep({
315 );318 );
316 const [pairingOpen, setPairingOpen] = useState(expandPairingInitially);319 const [pairingOpen, setPairingOpen] = useState(expandPairingInitially);
317 const [isPairing, setIsPairing] = useState(false);320 const [isPairing, setIsPairing] = useState(false);
318 const ready =321 const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds);
319 selectedIds.size > 0 &&322 const skippedLabels = environments
320 [...selectedIds].every((id) =>323 .filter((environment) => skippedIds.includes(environment.environmentId))
321 environments.some(324 .map((environment) => environment.label);
322 (environment) =>
323 environment.environmentId === id && environment.connection.phase === "connected",
324 ),
325 );
326 const continueRef = useRef<HTMLButtonElement>(null);325 const continueRef = useRef<HTMLButtonElement>(null);
327 useEffect(() => {326 useEffect(() => {
328 if (327 if (
...@@ -365,7 +364,7 @@ function ConnectionStep({...@@ -365,7 +364,7 @@ function ConnectionStep({
365 {environment.label}364 {environment.label}
366 </span>365 </span>
367 <span className="shrink-0 text-xs text-muted-foreground">366 <span className="shrink-0 text-xs text-muted-foreground">
368 {environment.connection.phase === "connected" ? "Connected" : "Connecting…"}367 {presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
369 </span>368 </span>
370 </span>369 </span>
371 {environment.displayUrl ? (370 {environment.displayUrl ? (
...@@ -421,6 +420,12 @@ function ConnectionStep({...@@ -421,6 +420,12 @@ function ConnectionStep({
421 </Collapsible>420 </Collapsible>
422 </div>421 </div>
423 </div>422 </div>
423 {skippedLabels.length > 0 ? (
424 <p className="mt-4 text-xs text-muted-foreground">
425 Not connected, so setup skips {skippedLabels.join(", ")}. You can set{" "}
426 {skippedLabels.length === 1 ? "it" : "them"} up later from Settings.
427 </p>
428 ) : null}
424 <div className="mt-6 flex flex-col-reverse gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">429 <div className="mt-6 flex flex-col-reverse gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
425 <p className="min-w-0 text-xs leading-relaxed text-muted-foreground">430 <p className="min-w-0 text-xs leading-relaxed text-muted-foreground">
426 T3 Code collects anonymous usage data to help us improve it. To read more about how your431 T3 Code collects anonymous usage data to help us improve it. To read more about how your
apps/web/src/onboarding/targetEnvironment.logic.test.ts+47
...@@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test";
99
10import {10import {
11 isOnboardingRelayEnvironment,11 isOnboardingRelayEnvironment,
12 resolveOnboardingSetup,
12 resolveOnboardingTargetEnvironment,13 resolveOnboardingTargetEnvironment,
13} from "./targetEnvironment.logic";14} from "./targetEnvironment.logic";
1415
...@@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => {...@@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => {
209 ).toEqual([olderRelay]);210 ).toEqual([olderRelay]);
210 });211 });
211});212});
213
214describe("resolveOnboardingSetup", () => {
215 const computer = (id: string, phase: string) => ({
216 environmentId: EnvironmentId.make(id),
217 connection: { phase },
218 });
219 const local = computer("local", "connected");
220
221 it("skips selected computers that will not connect on their own", () => {
222 const environments = [
223 local,
224 computer("switched-off", "available"),
225 computer("offline", "offline"),
226 computer("failing", "reconnecting"),
227 computer("old-client", "unsupported"),
228 ];
229 expect(
230 resolveOnboardingSetup(
231 environments,
232 new Set(environments.map((environment) => environment.environmentId)),
233 ),
234 ).toEqual({
235 ready: true,
236 environmentIds: [local.environmentId],
237 skippedIds: environments.slice(1).map((environment) => environment.environmentId),
238 });
239 });
240
241 it("waits for a first connection attempt to settle", () => {
242 const connecting = computer("new", "connecting");
243 expect(
244 resolveOnboardingSetup(
245 [local, connecting],
246 new Set([local.environmentId, connecting.environmentId]),
247 ).ready,
248 ).toBe(false);
249 });
250
251 it("is not ready without a connected selection", () => {
252 expect(resolveOnboardingSetup([local], new Set()).ready).toBe(false);
253 expect(
254 resolveOnboardingSetup([computer("off", "available")], new Set([EnvironmentId.make("off")]))
255 .ready,
256 ).toBe(false);
257 });
258});
apps/web/src/onboarding/targetEnvironment.logic.ts+28
...@@ -47,3 +47,31 @@ export function resolveOnboardingTargetEnvironment<TEnvironment extends Onboardi...@@ -47,3 +47,31 @@ export function resolveOnboardingTargetEnvironment<TEnvironment extends Onboardi
4747
48 return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null);48 return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null);
49}49}
50
51/**
52 * The computers the wizard sets up from the user's selection. Continue waits
53 * only on a first connection attempt, which settles on its own. Selected
54 * computers that are switched off, offline, failing, or unsupported are skipped
55 * so they can never lock the user out of onboarding.
56 */
57export function resolveOnboardingSetup(
58 environments: ReadonlyArray<Pick<OnboardingEnvironment, "environmentId" | "connection">>,
59 selectedIds: ReadonlySet<EnvironmentId>,
60): {
61 readonly ready: boolean;
62 readonly environmentIds: ReadonlyArray<EnvironmentId>;
63 readonly skippedIds: ReadonlyArray<EnvironmentId>;
64} {
65 const selected = environments.filter((environment) => selectedIds.has(environment.environmentId));
66 const idsInPhase = (keep: (phase: string) => boolean) =>
67 selected
68 .filter((environment) => keep(environment.connection.phase))
69 .map((environment) => environment.environmentId);
70 const environmentIds = idsInPhase((phase) => phase === "connected");
71 const settling = selected.some((environment) => environment.connection.phase === "connecting");
72 return {
73 ready: environmentIds.length > 0 && !settling,
74 environmentIds,
75 skippedIds: idsInPhase((phase) => phase !== "connected" && phase !== "connecting"),
76 };
77}