Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
121 changes: 121 additions & 0 deletions apps/web/src/components/HostedEmptyState.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Availability>): 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(<T3ConnectSteps />));

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]);
});
163 changes: 163 additions & 0 deletions apps/web/src/components/HostedEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
<WorkspacePageHeader electron={isElectron} className="border-b border-border">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
{APP_DISPLAY_NAME}
</span>
</div>
</WorkspacePageHeader>

<Empty className="flex-1">
<EmptyHeader>
<EmptyTitle>
{localEnvironmentOff ? "This computer is off" : "No computer connected"}
</EmptyTitle>
<EmptyDescription>
{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."}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
{localEnvironmentOff ? (
<Button render={<Link to="/settings/connections" />} size="sm">
Open Connections
</Button>
) : (
<ol className="w-full space-y-4 text-left">
{cloudEnabled ? <T3ConnectSteps /> : <PairingSteps />}
</ol>
)}
</EmptyContent>
</Empty>
</div>
</SidebarInset>
);
}

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<EnvironmentId>());
const [selectedIds, setSelectedIds] = useState<ReadonlySet<EnvironmentId>>(() => new Set());
const discovered = [...useRelayEnvironmentDiscovery().environments.values()];
const discoverySettled =
discovered.length > 0 && discovered.every(({ availability }) => availability !== "checking");
return (
<>
<SetupStep number={1}>
{isSignedIn ? (
<p className="flex items-center gap-1.5 text-muted-foreground">
<CheckIcon className="size-4" />
Signed in
</p>
) : (
<Button size="sm" disabled={!isLoaded} onClick={openAuthPrompt}>
Sign in
</Button>
)}
</SetupStep>
<SetupStep number={2}>
<RunOnComputer command="npx t3 connect" />
{isSignedIn ? (
<div className="mt-4 space-y-1.5">
<CloudEnvironmentConnectRows
primaryEnvironmentId={null}
savedEnvironments={[]}
refreshWhileEmpty
selection={{
...(discoverySettled ? { autoSelectedComputers: autoAddedComputers } : {}),
selectedIds,
onChange: (environmentId, selected) =>
setSelectedIds((current) => {
const next = new Set(current);
if (selected) next.add(environmentId);
else next.delete(environmentId);
return next;
}),
}}
empty={<p className="text-xs text-muted-foreground">Waiting for your computer…</p>}
/>
</div>
) : null}
</SetupStep>
</>
);
}

function PairingSteps() {
return (
<>
<SetupStep number={1}>
<RunOnComputer command="npx t3 pair" />
</SetupStep>
<SetupStep number={2}>
<Button render={<Link to="/settings/connections" />} size="sm">
Add pairing link
</Button>
</SetupStep>
</>
);
}

function SetupStep({
number,
children,
}: {
readonly number: number;
readonly children: ReactNode;
}) {
return (
<li className="flex gap-3">
<span className="w-2 shrink-0 pt-1 text-muted-foreground">{number}</span>
<div className="min-w-0 flex-1">{children}</div>
</li>
);
}

function RunOnComputer({ command }: { readonly command: string }) {
return (
<>
<p>Run on your computer:</p>
<CommandBlock command={command} className="mt-2" />
<a
className="mt-2 inline-block text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
href={DESKTOP_DOWNLOAD_URL}
target="_blank"
rel="noreferrer noopener"
>
Or download the desktop app
</a>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,14 @@ export function CloudEnvironmentConnectRows({
kind={machineKind}
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{environment.label}</span>
{unsupportedDetail !== null ? (
<span className="mt-0.5 block text-xs text-muted-foreground">
{unsupportedDetail}
</span>
) : null}
</span>
<Tooltip>
<TooltipTrigger
render={<span tabIndex={0} />}
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/cloud/ConnectOnboardingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading