diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx index a9403887d9e8..8b052d76168a 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx @@ -64,7 +64,7 @@ vi.mock("../../state/environments", () => { environmentId, label: environmentId, connection: { phase: "connected" }, - entry: { target: { _tag: "DirectConnectionTarget" } }, + entry: { enabled: true, target: { _tag: "DirectConnectionTarget" } }, }); const primary = environment("primary"); return { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx index e59a837d9adf..cf7c38ec5875 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx @@ -151,6 +151,7 @@ const primaryEnvironment = { label: "This computer", connection: { phase: "connected" }, entry: { + enabled: true, target: new PrimaryConnectionTarget({ environmentId: primaryId, label: "This computer", @@ -164,6 +165,7 @@ const remoteEnvironment = { label: "Paired computer", connection: { phase: "connected" }, entry: { + enabled: true, target: new BearerConnectionTarget({ environmentId: remoteId, label: "Paired computer", diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index 9c357213c369..439a2cc579de 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -38,6 +38,7 @@ vi.mock("../../state/environments", () => { environmentId: "test-env", label: "Computer", connection: { phase: "connected" }, + entry: { enabled: true }, }; return { useEnvironments: () => ({ environments: [environment] }), diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 9d1c96e19eed..e94078271799 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -58,7 +58,10 @@ import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; import { readProjects, useProjects } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; -import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic"; +import { + isOnboardingRelayEnvironment, + resolveOnboardingSetup, +} from "../../onboarding/targetEnvironment.logic"; import { useProjectScans } from "../../onboarding/useProjectScans"; import { projectEnvironment } from "../../state/projects"; import { serverEnvironment } from "../../state/server"; @@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { T3Wordmark } from "../T3Wordmark"; import { Alert, AlertDescription } from "../ui/alert"; @@ -141,11 +145,14 @@ export function WelcomeWizard({ for (const environment of newComputers) { autoSelectedComputers.current.add(environment.environmentId); } + // A computer the user switched off stays unselected until they pick it. + const enabledComputers = newComputers.filter((environment) => environment.entry.enabled); + if (enabledComputers.length === 0) return; setSelection( (current) => new Set([ ...(current ?? []), - ...newComputers.map((environment) => environment.environmentId), + ...enabledComputers.map((environment) => environment.environmentId), ]), ); }, [environments]); @@ -262,11 +269,7 @@ export function WelcomeWizard({ }) } onContinue={() => - startSetup( - environments - .filter((environment) => selectedIds.has(environment.environmentId)) - .map((environment) => environment.environmentId), - ) + startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds) } onPaired={(environmentId) => { setSelection(new Set([...selectedIds, environmentId])); @@ -315,14 +318,10 @@ function ConnectionStep({ ); const [pairingOpen, setPairingOpen] = useState(expandPairingInitially); const [isPairing, setIsPairing] = useState(false); - const ready = - selectedIds.size > 0 && - [...selectedIds].every((id) => - environments.some( - (environment) => - environment.environmentId === id && environment.connection.phase === "connected", - ), - ); + const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds); + const skippedLabels = environments + .filter((environment) => skippedIds.includes(environment.environmentId)) + .map((environment) => environment.label); const continueRef = useRef(null); useEffect(() => { if ( @@ -365,7 +364,7 @@ function ConnectionStep({ {environment.label} - {environment.connection.phase === "connected" ? "Connected" : "Connecting…"} + {presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel} {environment.displayUrl ? ( @@ -421,6 +420,12 @@ function ConnectionStep({ + {skippedLabels.length > 0 ? ( +

+ Not connected, so setup skips {skippedLabels.join(", ")}. You can set{" "} + {skippedLabels.length === 1 ? "it" : "them"} up later from Settings. +

+ ) : null}

T3 Code collects anonymous usage data to help us improve it. To read more about how your diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts index 9928f83b27db..c92f4cf4441c 100644 --- a/apps/web/src/onboarding/targetEnvironment.logic.test.ts +++ b/apps/web/src/onboarding/targetEnvironment.logic.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { isOnboardingRelayEnvironment, + resolveOnboardingSetup, resolveOnboardingTargetEnvironment, } from "./targetEnvironment.logic"; @@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => { ).toEqual([olderRelay]); }); }); + +describe("resolveOnboardingSetup", () => { + const computer = (id: string, phase: string) => ({ + environmentId: EnvironmentId.make(id), + connection: { phase }, + }); + const local = computer("local", "connected"); + + it("skips selected computers that will not connect on their own", () => { + const environments = [ + local, + computer("switched-off", "available"), + computer("offline", "offline"), + computer("failing", "reconnecting"), + computer("old-client", "unsupported"), + ]; + expect( + resolveOnboardingSetup( + environments, + new Set(environments.map((environment) => environment.environmentId)), + ), + ).toEqual({ + ready: true, + environmentIds: [local.environmentId], + skippedIds: environments.slice(1).map((environment) => environment.environmentId), + }); + }); + + it("waits for a first connection attempt to settle", () => { + const connecting = computer("new", "connecting"); + expect( + resolveOnboardingSetup( + [local, connecting], + new Set([local.environmentId, connecting.environmentId]), + ).ready, + ).toBe(false); + }); + + it("is not ready without a connected selection", () => { + expect(resolveOnboardingSetup([local], new Set()).ready).toBe(false); + expect( + resolveOnboardingSetup([computer("off", "available")], new Set([EnvironmentId.make("off")])) + .ready, + ).toBe(false); + }); +}); diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts index 665590ec990d..d382ffe32f39 100644 --- a/apps/web/src/onboarding/targetEnvironment.logic.ts +++ b/apps/web/src/onboarding/targetEnvironment.logic.ts @@ -47,3 +47,31 @@ export function resolveOnboardingTargetEnvironment>, + selectedIds: ReadonlySet, +): { + readonly ready: boolean; + readonly environmentIds: ReadonlyArray; + readonly skippedIds: ReadonlyArray; +} { + const selected = environments.filter((environment) => selectedIds.has(environment.environmentId)); + const idsInPhase = (keep: (phase: string) => boolean) => + selected + .filter((environment) => keep(environment.connection.phase)) + .map((environment) => environment.environmentId); + const environmentIds = idsInPhase((phase) => phase === "connected"); + const settling = selected.some((environment) => environment.connection.phase === "connecting"); + return { + ready: environmentIds.length > 0 && !settling, + environmentIds, + skippedIds: idsInPhase((phase) => phase !== "connected" && phase !== "connecting"), + }; +}