diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 528609d36e608..7d14e2cea858b 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/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/components/cloud/CloudEnvironmentConnectList.browser.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx index dfe3880654ffb..550f77addd455 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx @@ -254,6 +254,7 @@ describe("cloud onboarding discovery", () => { expect(checked()).toBe(false); expect(checkbox().hasAttribute("data-disabled")).toBe(true); expect(text()).toContain("Client not supported"); + expect(text()).toContain("Update your app"); await toggle(); expect(discovery.register).not.toHaveBeenCalled(); await act(async () => diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 20510fc134323..ef4a59df2bf84 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -387,7 +387,14 @@ export function CloudEnvironmentConnectRows({ kind={machineKind} className="size-4 shrink-0 text-muted-foreground" /> - {environment.label} + + {environment.label} + {unsupportedDetail !== null ? ( + + {unsupportedDetail} + + ) : null} + } diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 421eeae0fa8e6..409fa77f5fd2e 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -12,6 +12,7 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { isHostedStaticApp } from "~/hostedPairing"; import { useEnvironments, usePrimaryEnvironment, @@ -46,7 +47,10 @@ function ConfiguredConnectOnboardingDialog() { ConnectOnboardingOptOutSchema, ); - const { isReady: environmentsReady } = useEnvironments(); + const { environments, isReady: environmentsReady } = useEnvironments(); + // The empty hosted home already adds account computers after sign-in. + const homeScreenAddsComputers = + isHostedStaticApp() && environmentsReady && environments.length === 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); const primarySessionState = useAtomValue( primaryEnvironmentId === null @@ -110,7 +114,7 @@ function ConfiguredConnectOnboardingDialog() { const publishStepDecided = !canManageRelay || controller.linkState.target !== null; if (requestedAccount !== null && openForAccount === null) { - if (optOutAccounts.includes(requestedAccount)) { + if (homeScreenAddsComputers || optOutAccounts.includes(requestedAccount)) { setRequestedAccount(null); } else if (sessionScopesKnown && publishStepDecided) { setRequestedAccount(null); diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index 72fc3c1f01519..56ca254539e74 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 { completeOnboarding } from "../../onboarding/firstRun"; import { isFirstRunWorkspaceProvenanceAuthoritative, @@ -84,7 +83,7 @@ export function FirstRunGate({ const hydrated = hydrationStatus === "ready"; const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); const bootstrapped = useAllEnvironmentShellsBootstrapped(); - const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const { isReady: environmentCatalogReady } = useEnvironments(); const projects = useProjects(); const threadCount = useAtomValue(threadCountAtom); const soleThread = useAtomValue(soleThreadAtom); @@ -121,13 +120,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, @@ -146,7 +139,7 @@ export function FirstRunGate({ threadCount, }); - if (decision !== "wizard" && hydrated) { + if (hydrated) { const transitioned = transitionFirstRunGateState(gateState, { type: "evidence", decision: nextDecision, @@ -170,15 +163,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.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx index 760173b49060a..95a37ebeaefed 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx @@ -221,3 +221,29 @@ it("keeps setup open when saving completion fails and preserves the import warni }), ); }); + +it("requires explicit Close to complete local setup", async () => { + const onDone = vi.fn(); + 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()); + 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 2e87c712a02d3..17207a20629ff 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -217,11 +217,17 @@ export function WelcomeWizard({ }; return ( - event.cancel()}> + { + event.cancel(); + 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/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index ad0e972aac2d0..6a5126085162c 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, 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; @@ -155,7 +154,7 @@ function HostedPromptLinkLanding({ Continue here ) : null} - diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index ef61f95eff009..2b5d17e085aaf 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -256,101 +256,50 @@ 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", () => { it("keeps the shell hidden until client settings are hydrated", () => { - expect( - resolveHostedFirstRunDecision({ - hydrated: false, - completed: false, - catalogReady: true, - environmentCount: 0, - }), - ).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({ + expect(resolveHostedFirstRunDecision(false)).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 73f0f96f81cbd..234adc5f71b96 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; @@ -71,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; } @@ -157,23 +145,9 @@ export function resolveFirstRunDecision(input: FirstRunDecisionInput): { : { decision: "app", persistCompletion: input.workspaceAuthoritative }; } -export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { +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 }; - } - - 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 382c3cbb77ae0..b121de4837df8 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,14 +1,13 @@ 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 { 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 { HostedEmptyState } from "../components/HostedEmptyState"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; @@ -20,8 +19,6 @@ import { useThreadShells, } from "../state/entities"; import { useEnvironments } from "../state/environments"; -import { APP_DISPLAY_NAME } from "~/branding"; -import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -29,7 +26,7 @@ function ChatIndexRouteView() { if (authGateState.status === "hosted-static") { if (!isReady) return null; - if (environments.length === 0) return ; + if (environments.length === 0) return ; } return ; @@ -117,49 +114,3 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { export const Route = createFileRoute("/_chat/")({ component: ChatIndexRouteView, }); - -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."; - - return ( - -
    - -
    - - {APP_DISPLAY_NAME} - -
    -
    - - -
    - -
    - -
    - 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} -
    - -
    -
    -
    -
    -
    -
    - ); -} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index aaa80e39ca97d..7e58cb9b0502c 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -21,9 +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. 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 816a24e7d8447..41e70a8dccd5f 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -1,7 +1,16 @@ # 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. From home, it adds compatible computers linked to your account and +opens the app. + +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 @@ -21,6 +30,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