| 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,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"; |
| 58 | import { agentSessionImport } from "../../state/agentSessions"; | 58 | import { agentSessionImport } from "../../state/agentSessions"; |
| 59 | import { readProjects, useProjects } from "../../state/entities"; | 59 | import { readProjects, useProjects } from "../../state/entities"; |
| 60 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; | 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 | import { useProjectScans } from "../../onboarding/useProjectScans"; | 65 | import { useProjectScans } from "../../onboarding/useProjectScans"; |
| 63 | import { projectEnvironment } from "../../state/projects"; | 66 | import { projectEnvironment } from "../../state/projects"; |
| 64 | import { serverEnvironment } from "../../state/server"; | 67 | import { serverEnvironment } from "../../state/server"; |
| ... | @@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; | ... | @@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; |
| 78 | import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; | 81 | import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; |
| 79 | import { TerminalViewport } from "../ThreadTerminalDrawer"; | 82 | import { TerminalViewport } from "../ThreadTerminalDrawer"; |
| 80 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; | 83 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 84 | import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation"; | ||
| 81 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 85 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 82 | import { T3Wordmark } from "../T3Wordmark"; | 86 | import { T3Wordmark } from "../T3Wordmark"; |
| 83 | import { Alert, AlertDescription } from "../ui/alert"; | 87 | import { 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 your | 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,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { |
| 11 | isOnboardingRelayEnvironment, | 11 | isOnboardingRelayEnvironment, |
| 12 | resolveOnboardingSetup, | ||
| 12 | resolveOnboardingTargetEnvironment, | 13 | resolveOnboardingTargetEnvironment, |
| 13 | } from "./targetEnvironment.logic"; | 14 | } from "./targetEnvironment.logic"; |
| 14 | 15 | ||
| ... | @@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => { | ... | @@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => { |
| 209 | ).toEqual([olderRelay]); | 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,3 +47,31 @@ export function resolveOnboardingTargetEnvironment<TEnvironment extends Onboardi |
| 47 | 47 | ||
| 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 | */ | ||
| 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 | } |