| author | |
| committer | |
| log | db46c42744874ca123bc170a90c1faa312f20119 |
| tree | 6396142c4db1337046781a50dd07af9ce0e253bc |
| parent | 87c366b1206b13701fbcc38e459d457f0f9da2df |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 64 | environmentId, |
| 65 | 65 | label: environmentId, |
| 66 | 66 | connection: { phase: "connected" }, |
| 67 | entry: { target: { _tag: "DirectConnectionTarget" } }, | |
| 67 | entry: { enabled: true, target: { _tag: "DirectConnectionTarget" } }, | |
| 68 | 68 | }); |
| 69 | 69 | const primary = environment("primary"); |
| 70 | 70 | return { |
apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx+2| ... | ... | @@ -151,6 +151,7 @@ const primaryEnvironment = { |
| 151 | 151 | label: "This computer", |
| 152 | 152 | connection: { phase: "connected" }, |
| 153 | 153 | entry: { |
| 154 | enabled: true, | |
| 154 | 155 | target: new PrimaryConnectionTarget({ |
| 155 | 156 | environmentId: primaryId, |
| 156 | 157 | label: "This computer", |
| ... | ... | @@ -164,6 +165,7 @@ const remoteEnvironment = { |
| 164 | 165 | label: "Paired computer", |
| 165 | 166 | connection: { phase: "connected" }, |
| 166 | 167 | entry: { |
| 168 | enabled: true, | |
| 167 | 169 | target: new BearerConnectionTarget({ |
| 168 | 170 | environmentId: remoteId, |
| 169 | 171 | label: "Paired computer", |
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+1| ... | ... | @@ -38,6 +38,7 @@ vi.mock("../../state/environments", () => { |
| 38 | 38 | environmentId: "test-env", |
| 39 | 39 | label: "Computer", |
| 40 | 40 | connection: { phase: "connected" }, |
| 41 | entry: { enabled: true }, | |
| 41 | 42 | }; |
| 42 | 43 | return { |
| 43 | 44 | useEnvironments: () => ({ environments: [environment] }), |
apps/web/src/components/onboarding/WelcomeWizard.tsx+21-16| ... | ... | @@ -58,7 +58,10 @@ import { newProjectId, randomUUID } from "../../lib/utils"; |
| 58 | 58 | import { agentSessionImport } from "../../state/agentSessions"; |
| 59 | 59 | import { readProjects, useProjects } from "../../state/entities"; |
| 60 | 60 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; |
| 61 | import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic"; | |
| 61 | import { | |
| 62 | isOnboardingRelayEnvironment, | |
| 63 | resolveOnboardingSetup, | |
| 64 | } from "../../onboarding/targetEnvironment.logic"; | |
| 62 | 65 | import { useProjectScans } from "../../onboarding/useProjectScans"; |
| 63 | 66 | import { projectEnvironment } from "../../state/projects"; |
| 64 | 67 | import { serverEnvironment } from "../../state/server"; |
| ... | ... | @@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; |
| 78 | 81 | import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; |
| 79 | 82 | import { TerminalViewport } from "../ThreadTerminalDrawer"; |
| 80 | 83 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 84 | import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation"; | |
| 81 | 85 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 82 | 86 | import { T3Wordmark } from "../T3Wordmark"; |
| 83 | 87 | import { Alert, AlertDescription } from "../ui/alert"; |
| ... | ... | @@ -141,11 +145,14 @@ export function WelcomeWizard({ |
| 141 | 145 | for (const environment of newComputers) { |
| 142 | 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 | 151 | setSelection( |
| 145 | 152 | (current) => |
| 146 | 153 | new Set([ |
| 147 | 154 | ...(current ?? []), |
| 148 | ...newComputers.map((environment) => environment.environmentId), | |
| 155 | ...enabledComputers.map((environment) => environment.environmentId), | |
| 149 | 156 | ]), |
| 150 | 157 | ); |
| 151 | 158 | }, [environments]); |
| ... | ... | @@ -262,11 +269,7 @@ export function WelcomeWizard({ |
| 262 | 269 | }) |
| 263 | 270 | } |
| 264 | 271 | onContinue={() => |
| 265 | startSetup( | |
| 266 | environments | |
| 267 | .filter((environment) => selectedIds.has(environment.environmentId)) | |
| 268 | .map((environment) => environment.environmentId), | |
| 269 | ) | |
| 272 | startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds) | |
| 270 | 273 | } |
| 271 | 274 | onPaired={(environmentId) => { |
| 272 | 275 | setSelection(new Set([...selectedIds, environmentId])); |
| ... | ... | @@ -315,14 +318,10 @@ function ConnectionStep({ |
| 315 | 318 | ); |
| 316 | 319 | const [pairingOpen, setPairingOpen] = useState(expandPairingInitially); |
| 317 | 320 | const [isPairing, setIsPairing] = useState(false); |
| 318 | const ready = | |
| 319 | selectedIds.size > 0 && | |
| 320 | [...selectedIds].every((id) => | |
| 321 | environments.some( | |
| 322 | (environment) => | |
| 323 | environment.environmentId === id && environment.connection.phase === "connected", | |
| 324 | ), | |
| 325 | ); | |
| 321 | const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds); | |
| 322 | const skippedLabels = environments | |
| 323 | .filter((environment) => skippedIds.includes(environment.environmentId)) | |
| 324 | .map((environment) => environment.label); | |
| 326 | 325 | const continueRef = useRef<HTMLButtonElement>(null); |
| 327 | 326 | useEffect(() => { |
| 328 | 327 | if ( |
| ... | ... | @@ -365,7 +364,7 @@ function ConnectionStep({ |
| 365 | 364 | {environment.label} |
| 366 | 365 | </span> |
| 367 | 366 | <span className="shrink-0 text-xs text-muted-foreground"> |
| 368 | {environment.connection.phase === "connected" ? "Connected" : "Connecting…"} | |
| 367 | {presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel} | |
| 369 | 368 | </span> |
| 370 | 369 | </span> |
| 371 | 370 | {environment.displayUrl ? ( |
| ... | ... | @@ -421,6 +420,12 @@ function ConnectionStep({ |
| 421 | 420 | </Collapsible> |
| 422 | 421 | </div> |
| 423 | 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 | 429 | <div className="mt-6 flex flex-col-reverse gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6"> |
| 425 | 430 | <p className="min-w-0 text-xs leading-relaxed text-muted-foreground"> |
| 426 | 431 | 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 | 9 | |
| 10 | 10 | import { |
| 11 | 11 | isOnboardingRelayEnvironment, |
| 12 | resolveOnboardingSetup, | |
| 12 | 13 | resolveOnboardingTargetEnvironment, |
| 13 | 14 | } from "./targetEnvironment.logic"; |
| 14 | 15 | |
| ... | ... | @@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => { |
| 209 | 210 | ).toEqual([olderRelay]); |
| 210 | 211 | }); |
| 211 | 212 | }); |
| 213 | ||
| 214 | describe("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 | 47 | |
| 48 | 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 | */ | |
| 57 | export 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 | } |