From 472963251e5cffb7ebf661ad26f07813acb6894f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 16:43:14 -0700 Subject: [PATCH 01/12] fix(web): setup can be closed and no longer blocks Settings Co-Authored-By: Claude Opus 5.5 --- .../cloud/CloudEnvironmentConnectList.tsx | 10 ++++++++- .../components/onboarding/FirstRunGate.tsx | 10 +++++++-- .../onboarding/WelcomeWizard.test.tsx | 10 +++++++++ .../components/onboarding/WelcomeWizard.tsx | 21 +++++++++++++++---- docs/user/welcome-wizard.md | 7 +++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index ddff0de446b6c..a0c9a07fcc023 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -404,7 +404,15 @@ export function CloudEnvironmentConnectRows({ kind={machineKind} className="size-4 shrink-0 text-muted-foreground" /> - {environment.label} + + {environment.label} + {/* Say which side needs an update; a tooltip is easy to miss and absent on touch. */} + {unsupportedDetail !== null ? ( + + {unsupportedDetail} + + ) : null} + } diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index 15187b90defa0..522070dca7c0d 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -182,15 +182,21 @@ export function FirstRunGate({ return () => window.clearTimeout(timer); }, [decision, enabled, hydrated]); + // Setup opens by itself only from the home route. A direct link, such as + // Settings, opens as asked so setup can never lock a user out of the app. + const wizardOwnsRoute = pathname === "/"; useEffect(() => { - if (decision === "wizard" && pathname !== "/welcome") { + if (decision === "wizard" && wizardOwnsRoute) { void navigate({ to: "/welcome", replace: true }); } - }, [decision, navigate, pathname]); + }, [decision, navigate, wizardOwnsRoute]); if (settingsReadFailed) { return ; } + if (decision === "wizard" && !wizardOwnsRoute) { + return children; + } if (decision !== "app") { return stalled ? : null; } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index 439a2cc579de7..ad5b047d0d656 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -219,3 +219,13 @@ it("keeps setup open when saving completion fails and preserves the import warni }), ); }); + +it("closing setup on the first step completes onboarding and enters the app", async () => { + const onDone = vi.fn(); + await act(async () => root.render()); + const close = document.querySelector('button[aria-label="Close"]'); + expect(close).not.toBeNull(); + await act(async () => close!.click()); + expect(mocks.complete).toHaveBeenCalledOnce(); + expect(onDone).toHaveBeenCalledWith(undefined); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 2f7c942bedcef..d4d006bd873b8 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -105,8 +105,8 @@ import { formatRelativeTime } from "../../timestampFormat"; * fresh install (no completed-onboarding flag, empty workspace). Flow per the * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → * managed Codex setup or an inline CLI terminal → project import → main screen. - * Every step past the connection gate is skippable; the whole wizard is - * re-runnable by clearing the flag. + * The close button skips setup from any step and marks onboarding complete. + * Visiting `/welcome` runs the wizard again. */ type WizardStep = "connection" | "agents" | "import"; @@ -225,11 +225,19 @@ export function WelcomeWizard({ ); return ( - event.cancel()}> + { + event.cancel(); + // Only the close button skips setup. Escape stays with the setup + // terminal and pairing form, so a stray key press cannot end setup. + if (event.reason === "close-press" && !isImporting) void finish(); + }} + > document.getElementById("onboarding-pairing-url") ?? true} > ) : null} + {environment.connection.phase === "unsupported" && environment.connection.error ? ( + + {environment.connection.error} + + ) : null} ))} diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index 816a24e7d8447..c7441f6772dcf 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -3,6 +3,10 @@ T3 Code shows a setup flow when you open a new installation or connect to the hosted app for the first time. Existing workspaces skip this flow. +Close setup at any step to go straight to the app. You can connect computers +and agents later from Settings, or open `/welcome` to run setup again. Direct links, +such as Settings, open normally before setup is done. + ## Connect your computers Select one or more computers to set up. If you opened T3 Code directly from a @@ -21,6 +25,9 @@ You can add more computers before continuing: the pairing link. You can also run `t3 serve --host
` and use `t3 pair` when the server is already reachable on your network. +A computer marked **Client not supported** runs a T3 Code version that cannot +talk to this app. The message under its name says which side to update. + Saved computers and computers discovered through T3 Connect are selected by default. Uncheck any you do not want to set up; this does not disconnect them. Continue when your selected computers are connected. Setup checks From ee5bc5f9183faa6caafc26b7a6384a931fd493b5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 16:51:16 -0700 Subject: [PATCH 02/12] test(web): unsupported computers show the reason inline Co-Authored-By: Claude Opus 5.5 --- .../components/cloud/CloudEnvironmentConnectList.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx index f6ea5be622914..120b4210dc556 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -268,9 +268,10 @@ describe("cloud onboarding discovery", () => { expect(onSelectionChange).toHaveBeenCalledWith(newMachineId, false); expect(renderer!.root.findByType("input").props.checked).toBe(false); expect(renderer!.root.findByType("input").props.disabled).toBe(true); - expect(renderer!.root.findAllByType("span").flatMap((span) => span.children)).toContain( - "Client not supported", - ); + const rowText = renderer!.root.findAllByType("span").flatMap((span) => span.children); + expect(rowText).toContain("Client not supported"); + // The reason stays visible without a tooltip, which touch screens cannot open. + expect(rowText).toContainEqual(expect.stringContaining("Update your app")); await act(async () => { await renderer!.root.findByType("input").props.onChange({ target: { checked: true } }); }); From 13d9f5edd26e48ab8191f83e49a29097256125c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 05:27:48 -0700 Subject: [PATCH 03/12] fix(web): hosted empty state shows the command to run and a desktop download Co-Authored-By: Claude Opus 5.5 --- apps/web/src/branding.ts | 1 + .../promptLinks/HostedPromptLinkLanding.tsx | 5 +- apps/web/src/routes/_chat.index.tsx | 91 +++++++++++++------ 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 7fc57cf0d03fe..83e118a709c70 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -25,3 +25,4 @@ export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const DESKTOP_DOWNLOAD_URL = "https://t3.codes/download"; diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index 77fca435850a1..031e53b54ff55 100644 --- a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -9,7 +9,7 @@ import { useLocation } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "~/branding"; +import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { isHostedStaticApp } from "~/hostedPairing"; import { useEnvironments } from "~/state/environments"; @@ -21,7 +21,6 @@ import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; const OPENER_STORAGE_KEY = "t3code:prompt-link-opener"; const Opener = Schema.NullOr(Schema.Literals(["app", "browser"])); -const DOWNLOAD_URL = "https://t3.codes/download"; interface LinkSummary { readonly prompt: LinkedPrompt | null; @@ -166,7 +165,7 @@ function HostedPromptLinkLanding({ Continue here ) : null} - diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 4d39869dbefcd..71ab6c990e6fa 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,7 +1,6 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; -import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; @@ -9,7 +8,14 @@ import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; -import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { CommandBlock } from "../components/CommandBlock"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; @@ -20,7 +26,7 @@ import { useThreadShells, } from "../state/entities"; import { useEnvironments } from "../state/environments"; -import { APP_DISPLAY_NAME } from "~/branding"; +import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { @@ -132,14 +138,31 @@ export const Route = createFileRoute("/_chat/")({ component: ChatIndexRouteView, }); +/** + * Hosted app with no computer yet. Points at the one command that brings a + * computer online, then at Connections to add it. + */ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); - const localEnvironmentOff = isLocalEnvironmentDisabled(); - const description = localEnvironmentOff - ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." - : cloudEnabled - ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." - : "Open Connections and add that machine using its pairing link. This app must be able to reach it."; + // Desktop with its own computer switched off: it needs Connections, not a download. + const copy = isLocalEnvironmentDisabled() + ? { + title: "This computer is off", + body: "Turn it back on, or connect a different computer.", + command: null, + action: "Open Connections", + } + : { + title: "No computer connected", + body: "Your agents run on your computer. Start T3 Code there first.", + command: cloudEnabled ? "npx t3 connect" : "npx t3 pair", + action: cloudEnabled ? "Add your computer" : "Add pairing link", + }; + const action = ( + + ); return ( @@ -153,25 +176,37 @@ function HostedStaticOnboardingState() { -
- -
- -
- Connect to a computer running T3 Code - - This app connects to T3 Code running on your computer or a server. Start the T3 Code - desktop app or command-line server on that machine and keep it running. - - {description} -
- -
-
-
+ + {copy.title} + {copy.body} + + + {copy.command === null ? ( + action + ) : ( +
    +
  1. + 1 +
    +

    Run on your computer:

    + + + Or download the desktop app + +
    +
  2. +
  3. + 2 + {action} +
  4. +
+ )} +
From 06f1debb5e6108ac64763594a739173d7802e3d6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 05:44:41 -0700 Subject: [PATCH 04/12] fix(web): hosted empty state says sign in, then npx t3 connect Co-Authored-By: Claude Opus 5.5 --- apps/web/src/routes/_chat.index.tsx | 150 ++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 44 deletions(-) diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 71ab6c990e6fa..82c8d4ee852ff 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,7 +1,9 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useAuth } from "@clerk/react"; +import { CheckIcon } from "lucide-react"; +import { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { isElectron } from "../env"; @@ -9,6 +11,8 @@ import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { CommandBlock } from "../components/CommandBlock"; +import { useT3ConnectAuthPrompt } from "../components/clerk/useT3ConnectAuthPrompt"; +import { CloudEnvironmentConnectRows } from "../components/cloud/CloudEnvironmentConnectList"; import { Empty, EmptyContent, @@ -139,30 +143,13 @@ export const Route = createFileRoute("/_chat/")({ }); /** - * Hosted app with no computer yet. Points at the one command that brings a - * computer online, then at Connections to add it. + * Hosted app with no computer yet. Lists the steps that bring one online: + * T3 Connect sign-in plus `npx t3 connect`, or `npx t3 pair` without it. */ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); // Desktop with its own computer switched off: it needs Connections, not a download. - const copy = isLocalEnvironmentDisabled() - ? { - title: "This computer is off", - body: "Turn it back on, or connect a different computer.", - command: null, - action: "Open Connections", - } - : { - title: "No computer connected", - body: "Your agents run on your computer. Start T3 Code there first.", - command: cloudEnabled ? "npx t3 connect" : "npx t3 pair", - action: cloudEnabled ? "Add your computer" : "Add pairing link", - }; - const action = ( - - ); + const localEnvironmentOff = isLocalEnvironmentDisabled(); return ( @@ -177,33 +164,25 @@ function HostedStaticOnboardingState() { - {copy.title} - {copy.body} + + {localEnvironmentOff ? "This computer is off" : "No computer connected"} + + + {localEnvironmentOff + ? "Turn it back on, or connect a different computer." + : cloudEnabled + ? "Your agents run on your computer." + : "Your agents run on your computer. Start T3 Code there first."} + - {copy.command === null ? ( - action + {localEnvironmentOff ? ( + ) : (
    -
  1. - 1 -
    -

    Run on your computer:

    - - - Or download the desktop app - -
    -
  2. -
  3. - 2 - {action} -
  4. + {cloudEnabled ? : }
)}
@@ -212,3 +191,86 @@ function HostedStaticOnboardingState() {
); } + +/** Needs ClerkProvider, which the app mounts whenever cloud config exists. */ +function T3ConnectSteps() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + return ( + <> + + {isSignedIn ? ( +

+ + Signed in +

+ ) : ( + + )} +
+ + + {/* Linked computers show up here to add, so the user never leaves this screen. */} + {isSignedIn ? ( +
+ Waiting for your computer…

} + /> +
+ ) : null} +
+ + ); +} + +function PairingSteps() { + return ( + <> + + + + + + + + ); +} + +function SetupStep({ + number, + children, +}: { + readonly number: number; + readonly children: ReactNode; +}) { + return ( +
  • + {number} +
    {children}
    +
  • + ); +} + +function RunOnComputer({ command }: { readonly command: string }) { + return ( + <> +

    Run on your computer:

    + + + Or download the desktop app + + + ); +} From 8730ee93500ffb7bf258fdaab1b5e65577dc9739 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 06:04:31 -0700 Subject: [PATCH 05/12] fix(web): hosted app skips setup and adds linked computers after sign-in Co-Authored-By: Claude Opus 5.5 --- .../components/onboarding/FirstRunGate.tsx | 13 +--- .../web/src/onboarding/firstRun.logic.test.ts | 74 +------------------ apps/web/src/onboarding/firstRun.logic.ts | 34 ++------- apps/web/src/routes/_chat.index.tsx | 22 +++++- docs/user/remote-access.md | 3 +- docs/user/welcome-wizard.md | 8 +- 6 files changed, 40 insertions(+), 114 deletions(-) diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index 522070dca7c0d..64945dfb756fc 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -9,7 +9,6 @@ import { useClientSettings, useClientSettingsHydrationStatus, } from "../../hooks/useSettings"; -import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { isFirstRunWorkspaceProvenanceAuthoritative, @@ -41,7 +40,7 @@ import { Button } from "../ui/button"; * settings hydrate (the common case, no server round-trip). A `null` flag also * covers installs that predate the field, so it alone is not enough — the gate * waits for environment shells to bootstrap and inspects the workspace. - * Hosted mode instead checks its saved environment catalog. A timeout shows + * Hosted mode never opens setup; it only waits for settings. A timeout shows * recovery for an unreachable primary server without mounting the app tree. */ @@ -86,7 +85,7 @@ export function FirstRunGate({ const completeOnboarding = useCompleteOnboarding(); const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); const bootstrapped = useAllEnvironmentShellsBootstrapped(); - const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const { isReady: environmentCatalogReady } = useEnvironments(); const projects = useProjects(); const threads = useThreadShells(); const serverConfig = useAtomValue(primaryServerConfigAtom); @@ -126,13 +125,7 @@ export function FirstRunGate({ }); const { decision: nextDecision, persistCompletion } = hostedStatic - ? resolveHostedFirstRunDecision({ - localEnvironmentDisabled: isLocalEnvironmentDisabled(), - hydrated, - completed: onboardingCompletedAt !== null, - catalogReady: environmentCatalogReady, - environmentCount: environments.length, - }) + ? resolveHostedFirstRunDecision(hydrated) : resolveFirstRunDecision({ enabled, hydrated, diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index ef61f95eff009..47d21aa433c2f 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -275,82 +275,14 @@ describe("transitionFirstRunGateState", () => { describe("resolveHostedFirstRunDecision", () => { it("keeps the shell hidden until client settings are hydrated", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: false, - completed: false, - catalogReady: true, - environmentCount: 0, - }), - ).toEqual({ + expect(resolveHostedFirstRunDecision(false)).toEqual({ decision: "pending", persistCompletion: false, }); }); - it("waits for the saved environment catalog before judging a hosted install", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: false, - environmentCount: 0, - }), - ).toEqual({ - decision: "pending", - persistCompletion: false, - }); - }); - - it("opens onboarding when a hosted install has no saved environments", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: true, - environmentCount: 0, - }), - ).toEqual({ - decision: "wizard", - persistCompletion: false, - }); - }); - - it("keeps Connections reachable for a remote-only desktop predating onboarding", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: true, - environmentCount: 0, - localEnvironmentDisabled: true, - }), - ).toEqual({ decision: "app", persistCompletion: true }); - }); - - it("backfills onboarding for a hosted install with saved environments", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: false, - catalogReady: true, - environmentCount: 1, - }), - ).toEqual({ - decision: "app", - persistCompletion: true, - }); - }); - - it("opens the app immediately after hosted onboarding is complete", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: true, - completed: true, - catalogReady: false, - environmentCount: 0, - }), - ).toEqual({ + it("opens the app without setup, even with no saved computers", () => { + expect(resolveHostedFirstRunDecision(true)).toEqual({ decision: "app", persistCompletion: false, }); diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 28dae6124e7b0..85d27c578e390 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -48,14 +48,6 @@ interface FirstRunDecisionInput { readonly threadCount: number; } -interface HostedFirstRunDecisionInput { - readonly localEnvironmentDisabled?: boolean; - readonly hydrated: boolean; - readonly completed: boolean; - readonly catalogReady: boolean; - readonly environmentCount: number; -} - export function isFirstRunWorkspaceProvenanceAuthoritative(input: { readonly welcomeReceived: boolean; readonly bootstrapStatus: "pending" | "complete" | null; @@ -162,26 +154,14 @@ export function resolveFirstRunDecision(input: FirstRunDecisionInput): { : { decision: "app", persistCompletion: input.workspaceAuthoritative }; } -/** Hosted onboarding depends on saved environments because there is no primary server. */ -export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { +/** + * Hosted mode never opens setup by itself. Its home screen asks the user to + * sign in and adds the computers on their account. It only waits for + * settings, so unreadable settings still show recovery. + */ +export function resolveHostedFirstRunDecision(hydrated: boolean): { readonly decision: FirstRunDecision; readonly persistCompletion: boolean; } { - if (!input.hydrated) { - return { decision: "pending", persistCompletion: false }; - } - - if (input.completed) { - return { decision: "app", persistCompletion: false }; - } - - if (!input.catalogReady) { - return { decision: "pending", persistCompletion: false }; - } - - // An existing desktop may have disabled its server before onboarding existed. - // Keep Connections accessible so it can turn local execution back on. - return input.environmentCount === 0 && !input.localEnvironmentDisabled - ? { decision: "wizard", persistCompletion: false } - : { decision: "app", persistCompletion: true }; + return { decision: hydrated ? "app" : "pending", persistCompletion: false }; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 82c8d4ee852ff..3b354ff9820d5 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,5 +1,6 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId } from "@t3tools/contracts"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { useAuth } from "@clerk/react"; import { CheckIcon } from "lucide-react"; @@ -143,8 +144,9 @@ export const Route = createFileRoute("/_chat/")({ }); /** - * Hosted app with no computer yet. Lists the steps that bring one online: - * T3 Connect sign-in plus `npx t3 connect`, or `npx t3 pair` without it. + * Hosted app with no computer yet. This is hosted onboarding: setup never + * opens here. With T3 Connect it recommends signing in, then adds the linked + * computers by itself. Without T3 Connect it points at `npx t3 pair`. */ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); @@ -196,6 +198,10 @@ function HostedStaticOnboardingState() { function T3ConnectSteps() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { openAuthPrompt } = useT3ConnectAuthPrompt(); + // Selection mode adds each linked computer once, the same way setup does. + // The first one added replaces this screen with the app. + const [autoAddedComputers] = useState(() => new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); return ( <> @@ -212,13 +218,23 @@ function T3ConnectSteps() { - {/* Linked computers show up here to add, so the user never leaves this screen. */} {isSignedIn ? (
    + setSelectedIds((current) => { + const next = new Set(current); + if (selected) next.add(environmentId); + else next.delete(environmentId); + return next; + }), + }} empty={

    Waiting for your computer…

    } />
    diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 7ca2ae41aa853..34cb010e8f960 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -21,7 +21,8 @@ server with `t3 serve`. Saving your sign-in alone does not make the machine reachable. On your other device, sign in to the same T3 Connect account and choose the -environment. Over SSH, the CLI prints a browser link and a short code. Open the +environment. On app.t3.codes with no computer added yet, signing in adds your +linked computers for you. Over SSH, the CLI prints a browser link and a short code. Open the link on any device, confirm the code matches, and approve. The CLI continues on its own, so you do not need to forward an OAuth callback port. diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index c7441f6772dcf..af93e569a7779 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -1,7 +1,11 @@ # Welcome wizard -T3 Code shows a setup flow when you open a new installation or connect to the -hosted app for the first time. Existing workspaces skip this flow. +T3 Code shows a setup flow when you open a new installation. Existing +workspaces skip this flow. + +The hosted app at app.t3.codes does not open setup. With no computer connected, +it asks you to sign in to T3 Connect, then to run `npx t3 connect` on your +computer. It adds the computers linked to your account and opens the app. Close setup at any step to go straight to the app. You can connect computers and agents later from Settings, or open `/welcome` to run setup again. Direct links, From 0089300ec790826dc3b5112045f6cb7826b66056 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 06:10:12 -0700 Subject: [PATCH 06/12] fix(web): hosted sign-in adds every linked computer without a second dialog Co-Authored-By: Claude Opus 5.5 --- .../components/cloud/ConnectOnboardingDialog.tsx | 16 +++++++++++++--- apps/web/src/routes/_chat.index.tsx | 10 +++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index f793a127f43bc..912a6cf1e29e4 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -14,6 +14,7 @@ import { ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, } from "~/cloud/connectOnboarding"; +import { isHostedStaticApp } from "../../hostedPairing"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -39,6 +40,7 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro * current session is authorized to manage the relay link, then lists the * account's T3 Connect environments so every device can be connected right * away. A cold load with a restored session does not count as a sign-in. + * The hosted app with no computers skips it: its home screen adds them. */ export function ConnectOnboardingDialog() { if (!hasCloudPublicConfig()) return null; @@ -60,7 +62,10 @@ function ConfiguredConnectOnboardingDialog() { ConnectOnboardingOptOutSchema, ); - const { isReady: environmentsReady } = useEnvironments(); + const { environments, isReady: environmentsReady } = useEnvironments(); + // With no computers in this browser, the hosted home screen adds the + // account's computers after sign-in, so this wizard would repeat it. + const homeScreenAddsComputers = isHostedStaticApp() && environments.length === 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); const primarySessionState = useAtomValue( primaryEnvironmentId === null @@ -114,10 +119,15 @@ function ConfiguredConnectOnboardingDialog() { const previousAccount = observedAccountRef.current; const nextAccount = isSignedIn && userId ? userId : null; observedAccountRef.current = nextAccount; - if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { + if ( + previousAccount !== undefined && + previousAccount !== nextAccount && + nextAccount !== null && + !homeScreenAddsComputers + ) { setRequestedAccount(nextAccount); } - }, [isLoaded, isSignedIn, userId]); + }, [homeScreenAddsComputers, isLoaded, isSignedIn, userId]); // A manageable session implies a primary environment, so when the scopes // allow publishing, wait for the connection target too — otherwise the diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 3b354ff9820d5..ad62933a4244a 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -30,7 +30,7 @@ import { useProjects, useThreadShells, } from "../state/entities"; -import { useEnvironments } from "../state/environments"; +import { useEnvironments, useRelayEnvironmentDiscovery } from "../state/environments"; import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; @@ -199,9 +199,13 @@ function T3ConnectSteps() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { openAuthPrompt } = useT3ConnectAuthPrompt(); // Selection mode adds each linked computer once, the same way setup does. - // The first one added replaces this screen with the app. + // The first one added replaces this screen with the app, so wait until + // every computer's health check is done and add them all in one pass. const [autoAddedComputers] = useState(() => new Set()); const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const discovered = [...useRelayEnvironmentDiscovery().environments.values()]; + const discoverySettled = + discovered.length > 0 && discovered.every(({ availability }) => availability !== "checking"); return ( <> @@ -225,7 +229,7 @@ function T3ConnectSteps() { savedEnvironments={[]} refreshWhileEmpty selection={{ - autoSelectedComputers: autoAddedComputers, + ...(discoverySettled ? { autoSelectedComputers: autoAddedComputers } : {}), selectedIds, onChange: (environmentId, selected) => setSelectedIds((current) => { From d1247b30861ed81dc0e2490e667b17ac1039670e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 06:12:25 -0700 Subject: [PATCH 07/12] fix(web): T3 Connect wizard waits for the saved computer list before skipping Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/cloud/ConnectOnboardingDialog.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 912a6cf1e29e4..eb56dae0d15ae 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -64,8 +64,10 @@ function ConfiguredConnectOnboardingDialog() { const { environments, isReady: environmentsReady } = useEnvironments(); // With no computers in this browser, the hosted home screen adds the - // account's computers after sign-in, so this wizard would repeat it. - const homeScreenAddsComputers = isHostedStaticApp() && environments.length === 0; + // account's computers after sign-in, so this wizard would repeat it. An + // unloaded catalog is not known to be empty, so the wizard still opens. + const homeScreenAddsComputers = + isHostedStaticApp() && environmentsReady && environments.length === 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); const primarySessionState = useAtomValue( primaryEnvironmentId === null From 9418dc10501a1566361bc8a46ef8e475ec30c700 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 06:20:45 -0700 Subject: [PATCH 08/12] fix(web): T3 Connect wizard decides to skip once the computer list loads Co-Authored-By: Claude Opus 5.5 --- .../cloud/ConnectOnboardingDialog.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index eb56dae0d15ae..8e90259e0da68 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -64,8 +64,8 @@ function ConfiguredConnectOnboardingDialog() { const { environments, isReady: environmentsReady } = useEnvironments(); // With no computers in this browser, the hosted home screen adds the - // account's computers after sign-in, so this wizard would repeat it. An - // unloaded catalog is not known to be empty, so the wizard still opens. + // account's computers after sign-in, so this wizard would repeat it. A + // pending request waits for the catalog to load before this is decided. const homeScreenAddsComputers = isHostedStaticApp() && environmentsReady && environments.length === 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -121,15 +121,10 @@ function ConfiguredConnectOnboardingDialog() { const previousAccount = observedAccountRef.current; const nextAccount = isSignedIn && userId ? userId : null; observedAccountRef.current = nextAccount; - if ( - previousAccount !== undefined && - previousAccount !== nextAccount && - nextAccount !== null && - !homeScreenAddsComputers - ) { + if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { setRequestedAccount(nextAccount); } - }, [homeScreenAddsComputers, isLoaded, isSignedIn, userId]); + }, [isLoaded, isSignedIn, userId]); // A manageable session implies a primary environment, so when the scopes // allow publishing, wait for the connection target too — otherwise the @@ -141,6 +136,10 @@ function ConfiguredConnectOnboardingDialog() { // that chose "Don't show this again" are skipped. useEffect(() => { if (requestedAccount === null || openForAccount !== null) return; + if (homeScreenAddsComputers) { + setRequestedAccount(null); + return; + } if (optOutAccounts.includes(requestedAccount)) { setRequestedAccount(null); return; @@ -156,6 +155,7 @@ function ConfiguredConnectOnboardingDialog() { }, [ canManageRelay, controller.linkState.target, + homeScreenAddsComputers, openForAccount, optOutAccounts, publishStepDecided, From 9040dd23fcdf72276be8964c76645a5d286f2109 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:00:02 +0000 Subject: [PATCH 09/12] test(web): verify setup requires explicit dismissal --- .../onboarding/WelcomeWizard.browser.test.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx index b335dd7ccf4b6..95a37ebeaefed 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx @@ -222,9 +222,25 @@ it("keeps setup open when saving completion fails and preserves the import warni ); }); -it("closing setup on the first step completes onboarding and enters the app", async () => { +it("requires explicit Close to complete local setup", async () => { const onDone = vi.fn(); - await act(async () => root.render()); + await act(async () => root.render()); + await act(async () => { + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + const backdrop = document.querySelector('[data-slot="dialog-backdrop"]')!; + backdrop.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + backdrop.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + backdrop.click(); + }); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect(mocks.complete).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + const close = document.querySelector('button[aria-label="Close"]'); expect(close).not.toBeNull(); await act(async () => close!.click()); From 39a317d34883f183ef80cf418a49b8b197786908 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:06:06 +0000 Subject: [PATCH 10/12] refactor(web): simplify hosted onboarding code --- .../cloud/CloudEnvironmentConnectList.tsx | 1 - .../cloud/ConnectOnboardingDialog.tsx | 6 ++---- .../components/onboarding/WelcomeWizard.tsx | 2 -- apps/web/src/onboarding/firstRun.logic.ts | 5 ----- apps/web/src/routes/_chat.index.tsx | 18 +++++------------- 5 files changed, 7 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 18cc39be49acc..ef4a59df2bf84 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -389,7 +389,6 @@ export function CloudEnvironmentConnectRows({ /> {environment.label} - {/* Say which side needs an update; a tooltip is easy to miss and absent on touch. */} {unsupportedDetail !== null ? ( {unsupportedDetail} diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 71cfda74db51d..409fa77f5fd2e 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -9,10 +9,10 @@ import { ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, } from "~/cloud/connectOnboarding"; -import { isHostedStaticApp } from "../../hostedPairing"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { isHostedStaticApp } from "~/hostedPairing"; import { useEnvironments, usePrimaryEnvironment, @@ -48,9 +48,7 @@ function ConfiguredConnectOnboardingDialog() { ); const { environments, isReady: environmentsReady } = useEnvironments(); - // With no computers in this browser, the hosted home screen adds the - // account's computers after sign-in, so this wizard would repeat it. A - // pending request waits for the catalog to load before this is decided. + // The empty hosted home already adds account computers after sign-in. const homeScreenAddsComputers = isHostedStaticApp() && environmentsReady && environments.length === 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index d667ed5cab240..17207a20629ff 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -222,8 +222,6 @@ export function WelcomeWizard({ disablePointerDismissal onOpenChange={(_, event) => { event.cancel(); - // Only the close button skips setup. Escape stays with the setup - // terminal and pairing form, so a stray key press cannot end setup. if (event.reason === "close-press" && !isImporting) void finish(); }} > diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 93d4c4d506210..54cd4110d18e2 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -149,11 +149,6 @@ export function resolveFirstRunDecision(input: FirstRunDecisionInput): { : { decision: "app", persistCompletion: input.workspaceAuthoritative }; } -/** - * Hosted mode never opens setup by itself. Its home screen asks the user to - * sign in and adds the computers on their account. It only waits for - * settings, so unreadable settings still show recovery. - */ export function resolveHostedFirstRunDecision(hydrated: boolean): { readonly decision: FirstRunDecision; readonly persistCompletion: boolean; diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 4e23626b9f354..48a98eab25b2b 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,7 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { useAuth } from "@clerk/react"; import { CheckIcon } from "lucide-react"; -import { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { isElectron } from "../env"; @@ -40,7 +40,7 @@ function ChatIndexRouteView() { if (authGateState.status === "hosted-static") { if (!isReady) return null; - if (environments.length === 0) return ; + if (environments.length === 0) return ; } return ; @@ -129,14 +129,8 @@ export const Route = createFileRoute("/_chat/")({ component: ChatIndexRouteView, }); -/** - * Hosted app with no computer yet. This is hosted onboarding: setup never - * opens here. With T3 Connect it recommends signing in, then adds the linked - * computers by itself. Without T3 Connect it points at `npx t3 pair`. - */ -function HostedStaticOnboardingState() { +function HostedEmptyState() { const cloudEnabled = hasCloudPublicConfig(); - // Desktop with its own computer switched off: it needs Connections, not a download. const localEnvironmentOff = isLocalEnvironmentDisabled(); return ( @@ -180,13 +174,11 @@ function HostedStaticOnboardingState() { ); } -/** Needs ClerkProvider, which the app mounts whenever cloud config exists. */ function T3ConnectSteps() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { openAuthPrompt } = useT3ConnectAuthPrompt(); - // Selection mode adds each linked computer once, the same way setup does. - // The first one added replaces this screen with the app, so wait until - // every computer's health check is done and add them all in one pass. + // Adding the first computer unmounts this screen. Wait for all health checks + // so every compatible registration is queued before that happens. const [autoAddedComputers] = useState(() => new Set()); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const discovered = [...useRelayEnvironmentDiscovery().environments.values()]; From 58ef67de52b2996d6b8aa8c850b188f5ef2ed782 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:16:44 +0000 Subject: [PATCH 11/12] fix(web): refresh setup decision after workspace changes --- .../components/onboarding/FirstRunGate.tsx | 2 +- .../web/src/onboarding/firstRun.logic.test.ts | 37 ++++++++++++++----- apps/web/src/onboarding/firstRun.logic.ts | 6 +-- docs/user/remote-access.md | 9 +++-- docs/user/welcome-wizard.md | 9 +++-- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index cccade7b90715..56ca254539e74 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -139,7 +139,7 @@ export function FirstRunGate({ threadCount, }); - if (decision !== "wizard" && hydrated) { + if (hydrated) { const transitioned = transitionFirstRunGateState(gateState, { type: "evidence", decision: nextDecision, diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index 47d21aa433c2f..2b5d17e085aaf 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -256,21 +256,38 @@ describe("transitionFirstRunGateState", () => { }, ); - it("keeps recovery visible while evidence remains pending", () => { - const state = { decision: "pending", stalled: true } as const; + it.each([ + { decision: "pending", stalled: true }, + { decision: "wizard", stalled: false }, + ] as const)("retains $decision while evidence remains pending", (state) => { expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( state, ); }); - it("allows authoritative wizard evidence to replace an app decision", () => { - expect( - transitionFirstRunGateState( - { decision: "app", stalled: false }, - { type: "evidence", decision: "wizard" }, - ), - ).toEqual({ decision: "wizard", stalled: false }); - }); + it.each([ + { previous: "app", workspaceFresh: true, decision: "wizard" }, + { previous: "wizard", workspaceFresh: false, decision: "app" }, + ] as const)( + "updates $previous to $decision when authoritative workspace evidence changes", + ({ previous, workspaceFresh, decision }) => { + const evidence = resolveFirstRunDecision({ ...freshWorkspace, workspaceFresh }); + expect( + transitionFirstRunGateState( + { decision: previous, stalled: false }, + { type: "evidence", decision: evidence.decision }, + ), + ).toEqual({ decision, stalled: false }); + }, + ); + + it.each(["pending", "app", "wizard"] as const)( + "preserves state identity for unchanged %s evidence", + (decision) => { + const state = { decision, stalled: false }; + expect(transitionFirstRunGateState(state, { type: "evidence", decision })).toBe(state); + }, + ); }); describe("resolveHostedFirstRunDecision", () => { diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 54cd4110d18e2..234adc5f71b96 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -63,11 +63,7 @@ export function transitionFirstRunGateState( return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; } - if ( - state.decision === "wizard" || - event.decision === "pending" || - (state.decision === "app" && event.decision !== "wizard") - ) { + if (event.decision === "pending" || state.decision === event.decision) { return state; } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index bef953d160b5e..7e58cb9b0502c 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -21,10 +21,11 @@ server with `t3 serve`. Saving your sign-in alone does not make the machine reachable. On your other device, sign in to the same T3 Connect account and choose the -environment. On app.t3.codes with no computer added yet, signing in adds your -linked computers for you. Over SSH, the CLI prints a browser link and a short code. Open the -link on any device, confirm the code matches, and approve. The CLI continues on -its own, so you do not need to forward an OAuth callback port. +environment. On the app.t3.codes home page with no computer added yet, signing +in adds compatible computers linked to your account. Over SSH, the CLI prints a +browser link and a short code. Open the link on any device, confirm the code +matches, and approve. The CLI continues on its own, so you do not need to forward +an OAuth callback port. T3 Connect renews access credentials when needed without disconnecting a healthy connection. Pull request diffs and provider settings keep working after the diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index af93e569a7779..41e70a8dccd5f 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -5,11 +5,12 @@ workspaces skip this flow. The hosted app at app.t3.codes does not open setup. With no computer connected, it asks you to sign in to T3 Connect, then to run `npx t3 connect` on your -computer. It adds the computers linked to your account and opens the app. +computer. From home, it adds compatible computers linked to your account and +opens the app. -Close setup at any step to go straight to the app. You can connect computers -and agents later from Settings, or open `/welcome` to run setup again. Direct links, -such as Settings, open normally before setup is done. +Close setup while no import is running to go straight to the app. You can connect +computers and agents later from Settings, or open `/welcome` to run setup again. +Direct links, such as Settings, open normally before setup is done. ## Connect your computers From 033af2dad996193375bf94a32d93efa351ac2dd9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 19 Oct 2026 18:16:57 -0700 Subject: [PATCH 12/12] test(web): hosted sign-in adds every linked computer after discovery settles Moves the hosted empty state out of the index route so it can be tested. Co-Authored-By: Claude Opus 5.5 --- .../HostedEmptyState.browser.test.tsx | 121 +++++++++++++ apps/web/src/components/HostedEmptyState.tsx | 163 +++++++++++++++++ apps/web/src/routes/_chat.index.tsx | 168 +----------------- 3 files changed, 289 insertions(+), 163 deletions(-) create mode 100644 apps/web/src/components/HostedEmptyState.browser.test.tsx create mode 100644 apps/web/src/components/HostedEmptyState.tsx diff --git a/apps/web/src/components/HostedEmptyState.browser.test.tsx b/apps/web/src/components/HostedEmptyState.browser.test.tsx new file mode 100644 index 0000000000000..333bcf57a0fd9 --- /dev/null +++ b/apps/web/src/components/HostedEmptyState.browser.test.tsx @@ -0,0 +1,121 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; +type Availability = "checking" | "online"; + +const discovery = vi.hoisted(() => ({ + state: null as Discovery.RelayEnvironmentDiscoveryState | null, + listeners: new Set<() => void>(), + register: vi.fn(), +})); + +vi.mock(import("@clerk/react"), async (original) => ({ + ...(await original()), + useAuth: (() => ({ isLoaded: true, isSignedIn: true })) as never, +})); +vi.mock(import("./clerk/useT3ConnectAuthPrompt"), () => ({ + useT3ConnectAuthPrompt: () => ({ authPrompt: null, openAuthPrompt: vi.fn() }), +})); +vi.mock(import("~/state/use-atom-command"), async (original) => { + const { relayEnvironmentDiscovery } = await import("~/state/relay"); + return { + ...(await original()), + useAtomCommand: ((command: unknown) => + command === relayEnvironmentDiscovery.refresh + ? async () => AsyncResult.success(undefined) + : discovery.register) as never, + }; +}); +vi.mock(import("~/state/environments"), async (original) => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + discovery.listeners.add(listener); + return () => discovery.listeners.delete(listener); + }; + const read = () => discovery.state!; + return { + ...(await original()), + useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read), + }; +}); + +import { T3ConnectSteps } from "./HostedEmptyState"; + +const laptopId = EnvironmentId.make("laptop"); +const desktopId = EnvironmentId.make("desktop"); + +function linked(availability: Record): DiscoveredEnvironments { + return new Map( + [laptopId, desktopId].map((environmentId) => [ + environmentId, + { + environment: { + environmentId, + label: environmentId, + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test/ws", + providerKind: "manual", + }, + linkedAt: "2026-10-19T12:00:00.000Z", + }, + availability: availability[environmentId]!, + status: Option.none(), + error: Option.none(), + }, + ]), + ); +} + +async function publish(environments: DiscoveredEnvironments) { + discovery.state = { environments, refreshing: false, offline: false, error: Option.none() }; + await act(async () => { + for (const listener of discovery.listeners) listener(); + }); +} + +const registeredIds = () => + discovery.register.mock.calls.map(([registration]) => registration.target.environmentId); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + discovery.listeners.clear(); + discovery.state = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), + }; + discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// The first added computer replaces this screen with the app, so every +// registration has to start in the same pass, after all health checks finish. +it("adds every linked computer only after discovery finishes checking them", async () => { + await act(async () => root.render()); + + await publish(linked({ [laptopId]: "checking", [desktopId]: "checking" })); + await publish(linked({ [laptopId]: "online", [desktopId]: "checking" })); + expect(discovery.register).not.toHaveBeenCalled(); + + await publish(linked({ [laptopId]: "online", [desktopId]: "online" })); + expect(registeredIds().toSorted()).toEqual([desktopId, laptopId]); +}); diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx new file mode 100644 index 0000000000000..388ca176bc684 --- /dev/null +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -0,0 +1,163 @@ +import { useAuth } from "@clerk/react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { CheckIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { isElectron } from "../env"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { useRelayEnvironmentDiscovery } from "../state/environments"; +import { CommandBlock } from "./CommandBlock"; +import { useT3ConnectAuthPrompt } from "./clerk/useT3ConnectAuthPrompt"; +import { CloudEnvironmentConnectRows } from "./cloud/CloudEnvironmentConnectList"; +import { Button } from "./ui/button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { SidebarInset } from "./ui/sidebar"; +import { WorkspacePageHeader } from "./WorkspacePageHeader"; + +/** Home screen for the hosted app before any computer is added. */ +export function HostedEmptyState() { + const cloudEnabled = hasCloudPublicConfig(); + const localEnvironmentOff = isLocalEnvironmentDisabled(); + + return ( + +
    + +
    + + {APP_DISPLAY_NAME} + +
    +
    + + + + + {localEnvironmentOff ? "This computer is off" : "No computer connected"} + + + {localEnvironmentOff + ? "Turn it back on, or connect a different computer." + : cloudEnabled + ? "Your agents run on your computer." + : "Your agents run on your computer. Start T3 Code there first."} + + + + {localEnvironmentOff ? ( + + ) : ( +
      + {cloudEnabled ? : } +
    + )} +
    +
    +
    +
    + ); +} + +export function T3ConnectSteps() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + // Adding the first computer unmounts this screen. Wait for all health checks + // so every compatible registration is queued before that happens. + const [autoAddedComputers] = useState(() => new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const discovered = [...useRelayEnvironmentDiscovery().environments.values()]; + const discoverySettled = + discovered.length > 0 && discovered.every(({ availability }) => availability !== "checking"); + return ( + <> + + {isSignedIn ? ( +

    + + Signed in +

    + ) : ( + + )} +
    + + + {isSignedIn ? ( +
    + + setSelectedIds((current) => { + const next = new Set(current); + if (selected) next.add(environmentId); + else next.delete(environmentId); + return next; + }), + }} + empty={

    Waiting for your computer…

    } + /> +
    + ) : null} +
    + + ); +} + +function PairingSteps() { + return ( + <> + + + + + + + + ); +} + +function SetupStep({ + number, + children, +}: { + readonly number: number; + readonly children: ReactNode; +}) { + return ( +
  • + {number} +
    {children}
    +
  • + ); +} + +function RunOnComputer({ command }: { readonly command: string }) { + return ( + <> +

    Run on your computer:

    + + + Or download the desktop app + + + ); +} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 48a98eab25b2b..b121de4837df8 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,26 +1,14 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; -import { useAuth } from "@clerk/react"; -import { CheckIcon } from "lucide-react"; -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; -import { CommandBlock } from "../components/CommandBlock"; -import { useT3ConnectAuthPrompt } from "../components/clerk/useT3ConnectAuthPrompt"; -import { CloudEnvironmentConnectRows } from "../components/cloud/CloudEnvironmentConnectList"; -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from "../components/ui/empty"; +import { HostedEmptyState } from "../components/HostedEmptyState"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; @@ -30,9 +18,7 @@ import { useProjects, useThreadShells, } from "../state/entities"; -import { useEnvironments, useRelayEnvironmentDiscovery } from "../state/environments"; -import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; -import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { useEnvironments } from "../state/environments"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -128,147 +114,3 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { export const Route = createFileRoute("/_chat/")({ component: ChatIndexRouteView, }); - -function HostedEmptyState() { - const cloudEnabled = hasCloudPublicConfig(); - const localEnvironmentOff = isLocalEnvironmentDisabled(); - - return ( - -
    - -
    - - {APP_DISPLAY_NAME} - -
    -
    - - - - - {localEnvironmentOff ? "This computer is off" : "No computer connected"} - - - {localEnvironmentOff - ? "Turn it back on, or connect a different computer." - : cloudEnabled - ? "Your agents run on your computer." - : "Your agents run on your computer. Start T3 Code there first."} - - - - {localEnvironmentOff ? ( - - ) : ( -
      - {cloudEnabled ? : } -
    - )} -
    -
    -
    -
    - ); -} - -function T3ConnectSteps() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { openAuthPrompt } = useT3ConnectAuthPrompt(); - // Adding the first computer unmounts this screen. Wait for all health checks - // so every compatible registration is queued before that happens. - const [autoAddedComputers] = useState(() => new Set()); - const [selectedIds, setSelectedIds] = useState>(() => new Set()); - const discovered = [...useRelayEnvironmentDiscovery().environments.values()]; - const discoverySettled = - discovered.length > 0 && discovered.every(({ availability }) => availability !== "checking"); - return ( - <> - - {isSignedIn ? ( -

    - - Signed in -

    - ) : ( - - )} -
    - - - {isSignedIn ? ( -
    - - setSelectedIds((current) => { - const next = new Set(current); - if (selected) next.add(environmentId); - else next.delete(environmentId); - return next; - }), - }} - empty={

    Waiting for your computer…

    } - /> -
    - ) : null} -
    - - ); -} - -function PairingSteps() { - return ( - <> - - - - - - - - ); -} - -function SetupStep({ - number, - children, -}: { - readonly number: number; - readonly children: ReactNode; -}) { - return ( -
  • - {number} -
    {children}
    -
  • - ); -} - -function RunOnComputer({ command }: { readonly command: string }) { - return ( - <> -

    Run on your computer:

    - - - Or download the desktop app - - - ); -}