authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 20:38:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 03:38:47+00:00
logf729e8fd837e4d6de86781351c3aee5a4ea19ab3
tree22d7fb4ec76958b348a93b644b12d31c095cf6bf
parent490eb17d31d3037930d0e6b549393e9dbcebde60
signature Signed by PGP key B5690EEEBB952194

fix(web): make onboarding a shared multi-computer wizard (#10465)


20 files changed, 1199 insertions(+), 1277 deletions(-)

apps/web/src/components/AnimatedHeight.tsx+13-3
......@@ -4,7 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
44
55const HEIGHT_TRANSITION_FALLBACK_MS = 250;
66
7export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
7export function AnimatedHeight({
8 children,
9 holdHeight = false,
10}: {
11 readonly children: ReactNode;
12 /** Retain the previous content height while a replacement is loading. */
13 readonly holdHeight?: boolean;
14}) {
815 const contentRef = useRef<HTMLDivElement>(null);
916 const [heightState, setHeightState] = useState<{
1017 readonly height: number | null;
......@@ -22,6 +29,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
2229 }, [heightState.height, heightState.isClipping]);
2330
2431 useLayoutEffect(() => {
32 if (holdHeight) return;
2533 const element = contentRef.current;
2634 if (!element) return;
2735 let firstFrameId: number | null = null;
......@@ -67,7 +75,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
6775 resizeObserver.disconnect();
6876 cancelPendingFrames();
6977 };
70 }, []);
78 }, [holdHeight]);
7179
7280 return (
7381 <div
......@@ -85,7 +93,9 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
8593 );
8694 }}
8795 >
88 <div ref={contentRef}>{children}</div>
96 <div ref={contentRef} style={holdHeight ? { height: "100%" } : undefined}>
97 {children}
98 </div>
8999 </div>
90100 );
91101}
apps/web/src/components/NoProjectsHero.tsx created+36
......@@ -0,0 +1,36 @@
1import { PlusIcon } from "lucide-react";
2import { useCallback } from "react";
3
4import { openCommandPalette } from "../commandPaletteBus";
5import { Button } from "./ui/button";
6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
7import { SidebarInset } from "./ui/sidebar";
8
9export function NoProjectsHero() {
10 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
11
12 return (
13 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
14 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
15 <Empty className="flex-1">
16 <div className="w-full max-w-lg px-8 py-12">
17 <EmptyHeader className="max-w-none">
18 <EmptyTitle className="text-foreground text-2xl sm:text-3xl">
19 What should we work on?
20 </EmptyTitle>
21 <EmptyDescription className="mt-2 text-sm text-muted-foreground/78">
22 Add a project to start your first thread.
23 </EmptyDescription>
24 <div className="mt-6 flex justify-center">
25 <Button size="sm" onClick={openAddProject}>
26 <PlusIcon className="size-4" />
27 Add project
28 </Button>
29 </div>
30 </EmptyHeader>
31 </div>
32 </Empty>
33 </div>
34 </SidebarInset>
35 );
36}
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+111-1
......@@ -3,7 +3,7 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
33import { EnvironmentId } from "@t3tools/contracts";
44import * as Option from "effect/Option";
55import { AsyncResult } from "effect/unstable/reactivity";
6import { act, type ButtonHTMLAttributes } from "react";
6import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
77import { create, type ReactTestRenderer } from "react-test-renderer";
88import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
99
......@@ -42,6 +42,25 @@ vi.mock("~/state/environments", async () => {
4242 return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) };
4343});
4444vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null }));
45vi.mock("../ui/tooltip", () => ({
46 Tooltip: ({ children }: { children: ReactNode }) => children,
47 TooltipTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
48 TooltipPopup: () => null,
49}));
50vi.mock("../ui/checkbox", () => ({
51 Checkbox: (props: {
52 checked: boolean;
53 disabled: boolean;
54 onCheckedChange: (checked: boolean) => void;
55 }) => (
56 <input
57 type="checkbox"
58 checked={props.checked}
59 disabled={props.disabled}
60 onChange={(event) => props.onCheckedChange(event.target.checked)}
61 />
62 ),
63}));
4564vi.mock("../ui/button", () => ({
4665 Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
4766 <button {...props}>{children}</button>
......@@ -118,6 +137,7 @@ beforeEach(() => {
118137 error: Option.none(),
119138 };
120139 discovery.listEnvironments.mockReset().mockResolvedValue(new Map());
140 discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined));
121141 discovery.refresh.mockReset().mockImplementation(async () => {
122142 publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() });
123143 const environments = await discovery.listEnvironments();
......@@ -133,6 +153,71 @@ afterEach(async () => {
133153});
134154
135155describe("cloud onboarding discovery", () => {
156 it("signals that the section can expand after initial discovery settles", async () => {
157 let finishDiscovery!: (environments: DiscoveredEnvironments) => void;
158 discovery.listEnvironments.mockReturnValue(
159 new Promise((resolve) => {
160 finishDiscovery = resolve;
161 }),
162 );
163 const onDiscoveryReady = vi.fn();
164 await act(async () => {
165 renderer = create(
166 <CloudEnvironmentConnectRows
167 primaryEnvironmentId={null}
168 savedEnvironments={[]}
169 onDiscoveryReady={onDiscoveryReady}
170 />,
171 );
172 });
173 expect(onDiscoveryReady).not.toHaveBeenCalled();
174 await act(async () => {
175 finishDiscovery(linkedMachines);
176 });
177 expect(onDiscoveryReady).toHaveBeenCalledTimes(1);
178 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
179 });
180
181 it("connects and selects discovered computers by default without overwriting deselection", async () => {
182 discovery.listEnvironments.mockResolvedValue(linkedMachines);
183 const autoSelectedComputers = new Set<EnvironmentId>();
184 function Setup() {
185 const [selectedIds, setSelectedIds] = useState<ReadonlySet<EnvironmentId>>(new Set());
186 return (
187 <CloudEnvironmentConnectRows
188 primaryEnvironmentId={null}
189 savedEnvironments={[]}
190 showSavedEnvironments
191 selection={{
192 autoSelectedComputers,
193 selectedIds,
194 onChange: (id, checked) =>
195 setSelectedIds((current) => {
196 const next = new Set(current);
197 if (checked) next.add(id);
198 else next.delete(id);
199 return next;
200 }),
201 }}
202 />
203 );
204 }
205 await act(async () => {
206 renderer = create(<Setup />);
207 });
208
209 expect(discovery.register).toHaveBeenCalledTimes(1);
210 expect(renderer!.root.findByType("input").props.checked).toBe(true);
211 await act(async () => {
212 await renderer!.root.findByType("input").props.onChange({ target: { checked: false } });
213 });
214 await act(async () => {
215 publish({ ...discovery.state!, environments: new Map(linkedMachines) });
216 });
217 expect(renderer!.root.findByType("input").props.checked).toBe(false);
218 expect(discovery.register).toHaveBeenCalledTimes(1);
219 });
220
136221 it("shows a newly linked computer without remounting and stops polling once found", async () => {
137222 discovery.listEnvironments
138223 .mockResolvedValueOnce(new Map())
......@@ -152,6 +237,31 @@ describe("cloud onboarding discovery", () => {
152237 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
153238 });
154239
240 it("keeps a discovered computer visible when it is added to the browser", async () => {
241 discovery.listEnvironments.mockResolvedValue(linkedMachines);
242 await mount();
243 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
244 await act(async () => {
245 renderer!.update(
246 <CloudEnvironmentConnectRows
247 primaryEnvironmentId={null}
248 savedEnvironments={[
249 {
250 environmentId: newMachineId,
251 connection: { phase: "connected", error: null, traceId: null },
252 },
253 ]}
254 showSavedEnvironments
255 refreshWhileEmpty
256 />,
257 );
258 });
259 expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([
260 "Work laptop",
261 ]);
262 expect(renderer!.root.findByType("button").children).toEqual(["Connected"]);
263 });
264
155265 it("waits while hidden and refreshes immediately when visible again", async () => {
156266 page.visibilityState = "hidden";
157267 await mount();
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+94-12
......@@ -20,9 +20,11 @@ import { useRelayEnvironmentDiscovery } from "~/state/environments";
2020import { useAtomCommand } from "~/state/use-atom-command";
2121import { ConnectionStatusDot } from "../ConnectionStatusDot";
2222import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
23import { Checkbox } from "../ui/checkbox";
2324import { Button } from "../ui/button";
2425import { Skeleton } from "../ui/skeleton";
2526import { toastManager } from "../ui/toast";
27import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2628import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
2729
2830const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;
......@@ -59,12 +61,20 @@ export function CloudEnvironmentConnectRows({
5961 showSavedEnvironments = false,
6062 refreshWhileEmpty = false,
6163 empty = null,
64 selection,
65 onDiscoveryReady,
6266}: {
6367 readonly primaryEnvironmentId: EnvironmentId | null;
6468 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;
6569 readonly showSavedEnvironments?: boolean;
6670 readonly refreshWhileEmpty?: boolean;
6771 readonly empty?: ReactNode;
72 readonly onDiscoveryReady?: () => void;
73 readonly selection?: {
74 readonly autoSelectedComputers?: Set<EnvironmentId>;
75 readonly selectedIds: ReadonlySet<EnvironmentId>;
76 readonly onChange: (environmentId: EnvironmentId, selected: boolean) => void;
77 };
6878}) {
6979 const environmentsState = useRelayEnvironmentDiscovery();
7080 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
......@@ -89,33 +99,43 @@ export function CloudEnvironmentConnectRows({
8999 ),
90100 [registerEnvironment],
91101 );
92 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
93 null,
94 );
102 const [connectingEnvironmentIds, setConnectingEnvironmentIds] = useState<
103 ReadonlySet<EnvironmentId>
104 >(new Set());
95105 const savedById = new Map(
96106 savedEnvironments.map((environment) => [environment.environmentId, environment]),
97107 );
98108
99109 useEffect(() => {
100 if (!refreshWhileEmpty || document.visibilityState === "visible") {
101 void refreshRelayEnvironments();
110 let active = true;
111 if (onDiscoveryReady || !refreshWhileEmpty || document.visibilityState === "visible") {
112 void refreshRelayEnvironments().then(() => {
113 if (active) onDiscoveryReady?.();
114 });
102115 }
103 }, [refreshRelayEnvironments, refreshWhileEmpty]);
116 return () => {
117 active = false;
118 };
119 }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]);
104120
105121 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
106 setConnectingEnvironmentId(environment.environmentId);
122 setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId]));
107123 const result = await connectRelayEnvironment(environment);
108 setConnectingEnvironmentId(null);
124 setConnectingEnvironmentIds((current) => {
125 const next = new Set(current);
126 next.delete(environment.environmentId);
127 return next;
128 });
109129 if (result._tag === "Success") {
110130 toastManager.add({
111131 type: "success",
112132 title: "Environment added",
113133 description: `Connecting to ${environment.label} through T3 Connect.`,
114134 });
115 return;
135 return true;
116136 }
117137 if (isAtomCommandInterrupted(result)) {
118 return;
138 return false;
119139 }
120140 const cause = squashAtomCommandFailure(result);
121141 const message =
......@@ -135,6 +155,7 @@ export function CloudEnvironmentConnectRows({
135155 }
136156 : undefined,
137157 });
158 return false;
138159 };
139160
140161 const visibleEnvironments = [...environmentsState.environments.values()].filter(
......@@ -142,6 +163,25 @@ export function CloudEnvironmentConnectRows({
142163 environment.environmentId !== primaryEnvironmentId &&
143164 (showSavedEnvironments || !savedById.has(environment.environmentId)),
144165 );
166 const selectNewComputers = useEffectEvent(() => {
167 const seen = selection?.autoSelectedComputers;
168 if (!selection || !seen) return;
169 for (const { environment } of visibleEnvironments) {
170 const id = environment.environmentId;
171 if (seen.has(id)) continue;
172 seen.add(id);
173 selection.onChange(id, true);
174 if (!savedById.has(id)) {
175 void connectEnvironment(environment).then((connected) => {
176 if (!connected) selection.onChange(id, false);
177 });
178 }
179 }
180 });
181 useEffect(() => {
182 selectNewComputers();
183 }, [environmentsState.environments]);
184
145185 // Discovery clears its list on refresh, so poll only until a machine appears.
146186 const shouldRefreshWhileEmpty =
147187 refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline;
......@@ -254,6 +294,48 @@ export function CloudEnvironmentConnectRows({
254294 : availability === "checking"
255295 ? "Available · Checking relay status…"
256296 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");
297 if (selection) {
298 return (
299 <label
300 key={environment.environmentId}
301 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 has-disabled:cursor-default"
302 >
303 <Checkbox
304 checked={selection.selectedIds.has(environment.environmentId)}
305 disabled={connectingEnvironmentIds.has(environment.environmentId)}
306 onCheckedChange={async (checked) => {
307 selection.onChange(environment.environmentId, checked);
308 if (checked && !savedEnvironment) {
309 const connected = await connectEnvironment(environment);
310 if (!connected) selection.onChange(environment.environmentId, false);
311 }
312 }}
313 />
314 <span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span>
315 <Tooltip>
316 <TooltipTrigger
317 render={<span tabIndex={0} />}
318 className={cn(
319 "max-w-[45%] shrink-0 truncate text-xs",
320 savedConnection?.tone === "error" ? "text-destructive" : "text-muted-foreground",
321 )}
322 >
323 {connectingEnvironmentIds.has(environment.environmentId)
324 ? "Connecting…"
325 : (savedConnection?.buttonLabel ??
326 (availability === "online"
327 ? "Available"
328 : availability === "offline"
329 ? "Offline"
330 : availability === "error"
331 ? "Unavailable"
332 : "Checking…"))}
333 </TooltipTrigger>
334 <TooltipPopup className="max-w-80 break-words">{statusText}</TooltipPopup>
335 </Tooltip>
336 </label>
337 );
338 }
257339 return (
258340 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
259341 <div className={ITEM_ROW_INNER_CLASSNAME}>
......@@ -301,10 +383,10 @@ export function CloudEnvironmentConnectRows({
301383 ) : (
302384 <Button
303385 size="sm"
304 disabled={connectingEnvironmentId !== null}
386 disabled={connectingEnvironmentIds.size > 0}
305387 onClick={() => void connectEnvironment(environment)}
306388 >
307 {connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}
389 {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"}
308390 </Button>
309391 )}
310392 </div>
apps/web/src/components/onboarding/FirstRunGate.tsx+1-9
......@@ -2,14 +2,13 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
22import { useAtomValue } from "@effect/atom-react";
33import { useLocation, useNavigate } from "@tanstack/react-router";
44import { Atom } from "effect/unstable/reactivity";
5import { useEffect, useLayoutEffect, useState } from "react";
5import { useEffect, useState } from "react";
66
77import {
88 ensureClientSettingsHydrated,
99 useClientSettings,
1010 useClientSettingsHydrationStatus,
1111} from "../../hooks/useSettings";
12import { mountOnboardingTheme } from "../../hooks/useTheme";
1312import { useCompleteOnboarding } from "../../onboarding/firstRun";
1413import {
1514 isFirstRunWorkspaceProvenanceAuthoritative,
......@@ -104,13 +103,6 @@ export function FirstRunGate({
104103 }));
105104 const { decision, stalled } = gateState;
106105 const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying";
107 const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard";
108
109 useLayoutEffect(() => {
110 if (!ownsOnboardingTheme) return;
111 return mountOnboardingTheme();
112 }, [ownsOnboardingTheme]);
113
114106 // A workspace still counts as fresh when its only content is the server's
115107 // own cwd auto-bootstrap: web mode creates a project + thread from cwd at
116108 // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no
apps/web/src/components/onboarding/WelcomeWizard.tsx+550-683
......@@ -1,7 +1,6 @@
11import { useAuth } from "@clerk/react";
22import { useAtomValue } from "@effect/atom-react";
33import type {
4 AgentSessionProjectCandidate,
54 EnvironmentId,
65 ProjectId,
76 ScopedProjectRef,
......@@ -18,25 +17,23 @@ import * as Schema from "effect/Schema";
1817import {
1918 ArrowRightIcon,
2019 CheckIcon,
21 ChevronLeftIcon,
2220 ChevronRightIcon,
2321 CloudIcon,
2422 CopyIcon,
2523 LinkIcon,
2624 MonitorIcon,
2725 TerminalIcon,
28 type LucideIcon,
2926} from "lucide-react";
30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
27import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3128
3229import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
3330import { useLocalStorage } from "../../hooks/useLocalStorage";
34import { mountOnboardingTheme } from "../../hooks/useTheme";
3531import { hasCloudPublicConfig } from "../../cloud/publicConfig";
3632import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
3733import { useCompleteOnboarding } from "../../onboarding/firstRun";
3834import {
3935 partitionOnboardingProjects,
36 onboardingProjectKey,
4037 resolveOnboardingLandingProject,
4138 resolveOnboardingProjectId,
4239} from "../../onboarding/projectImport.logic";
......@@ -46,36 +43,37 @@ import {
4643 resolveOnboardingProviderLoginCommand,
4744 selectOnboardingProvidersByDriver,
4845} from "../../onboarding/providerReadiness.logic";
49import {
50 isOnboardingRelayEnvironment,
51 resolveOnboardingTargetEnvironment,
52} from "../../onboarding/targetEnvironment.logic";
5346import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
5447import { newProjectId, randomUUID } from "../../lib/utils";
55import { agentSessionImport, agentSessionScan } from "../../state/agentSessions";
48import { agentSessionImport } from "../../state/agentSessions";
5649import { readProjects, useProjects } from "../../state/entities";
5750import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
58import { useEnvironmentQuery } from "../../state/query";
51import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic";
52import { useProjectScans } from "../../onboarding/useProjectScans";
5953import { projectEnvironment } from "../../state/projects";
6054import { serverEnvironment } from "../../state/server";
6155import { terminalEnvironment } from "../../state/terminal";
6256import { useAtomCommand } from "../../state/use-atom-command";
6357import { connectPairing } from "../../connection/onboarding";
64import { isElectron } from "../../env";
65import { formatRelativeTimeLabel } from "../../timestampFormat";
6658import { getProviderSummary } from "../settings/providerStatus";
6759import { getDriverOption } from "../settings/providerDriverMeta";
68import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
6960import { TerminalViewport } from "../ThreadTerminalDrawer";
61import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
62import { T3Wordmark } from "../T3Wordmark";
7063import { Button } from "../ui/button";
7164import { Checkbox } from "../ui/checkbox";
7265import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
7366import { Input } from "../ui/input";
67import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
68import { ScrollArea } from "../ui/scroll-area";
69import { Spinner } from "../ui/spinner";
70import { WizardPanel, WizardSteps } from "../ui/wizard";
71import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
7472import { toastManager } from "../ui/toast";
7573import { cn } from "../../lib/utils";
7674
7775/**
78 * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a
76 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
7977 * fresh install (no completed-onboarding flag, empty workspace). Flow per the
8078 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →
8179 * agent setup with inline install terminal → project import → main screen.
......@@ -83,32 +81,8 @@ import { cn } from "../../lib/utils";
8381 * re-runnable by clearing the flag.
8482 */
8583
86type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import";
87
88type ConnectionMode = "local" | "connect" | "direct";
89
90/**
91 * The machine the agent and import steps run against. Local mode targets the
92 * primary environment; the remote modes prefer the machine the user just
93 * connected (the most recently added connected non-primary environment), so
94 * probing and import happen where their code lives rather than on the local
95 * server that happens to serve the app. Deliberately not a persisted
96 * "primary machine" concept — just whichever machine fits the chosen path
97 * right now, labeled inline on each step.
98 */
99function useOnboardingTargetEnvironment(
100 mode: ConnectionMode,
101 pairedEnvironmentId: EnvironmentId | null,
102) {
103 const { environments } = useEnvironments();
104 const primaryEnvironment = usePrimaryEnvironment();
105 return resolveOnboardingTargetEnvironment({
106 mode,
107 environments,
108 primaryEnvironment,
109 pairedEnvironmentId,
110 });
111}
84type WizardStep = "connection" | "agents" | "import";
85const NO_ENVIRONMENTS: readonly EnvironmentId[] = [];
11286
11387const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup");
11488const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const;
......@@ -118,23 +92,48 @@ export function WelcomeWizard({
11892 localAvailable,
11993 onDone,
12094}: {
121 /**
122 * Whether the "Local Only" card is offered. True whenever the app is served
123 * by an authenticated primary server — desktop, `npx t3`, or a dev server —
124 * since that server is "this machine" regardless of the hostname the app
125 * was opened from. Only hosted-static (app.t3.codes) has no local server.
126 */
95 /** Whether this client is authenticated to the server serving the app. */
12796 readonly localAvailable: boolean;
12897 readonly onDone: (projectRef?: ScopedProjectRef) => void;
12998}) {
130 useLayoutEffect(() => mountOnboardingTheme(), []);
13199 const completeOnboarding = useCompleteOnboarding();
132100 const [step, setStep] = useState<WizardStep>("connection");
133 const [mode, setMode] = useState<ConnectionMode>("local");
134 const [pairedEnvironmentId, setPairedEnvironmentId] = useState<EnvironmentId | null>(null);
101 const { environments } = useEnvironments();
102 const [selection, setSelection] = useState<ReadonlySet<EnvironmentId> | null>(null);
103 const autoSelectedComputers = useRef(new Set<EnvironmentId>());
104 const [setupIds, setSetupIds] = useState<readonly EnvironmentId[]>([]);
105 const [isImporting, setIsImporting] = useState(false);
135106 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);
136107 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
137 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
108 const primaryEnvironment = usePrimaryEnvironment();
109 useEffect(() => {
110 const newComputers = environments.filter(
111 (environment) => !autoSelectedComputers.current.has(environment.environmentId),
112 );
113 if (newComputers.length === 0) return;
114 for (const environment of newComputers) {
115 autoSelectedComputers.current.add(environment.environmentId);
116 }
117 setSelection(
118 (current) =>
119 new Set([
120 ...(current ?? []),
121 ...newComputers.map((environment) => environment.environmentId),
122 ]),
123 );
124 }, [environments]);
125 const selectedIds =
126 selection ?? new Set(primaryEnvironment ? [primaryEnvironment.environmentId] : []);
127 const scans = useProjectScans(step === "import" ? setupIds : NO_ENVIRONMENTS);
128 const isLoadingProjects =
129 step === "import" &&
130 scans.every((scan) => scan.data === null) &&
131 scans.some((scan) => scan.isPending);
132 const startSetup = (ids: readonly EnvironmentId[]) => {
133 if (ids.length === 0) return;
134 setSetupIds(ids);
135 setStep("agents");
136 };
138137 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
139138 const finish = useCallback(
140139 (projectRef?: ScopedProjectRef) => {
......@@ -178,178 +177,216 @@ export function WelcomeWizard({
178177 );
179178
180179 return (
181 <div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
182 {isElectron ? (
183 <div
184 aria-hidden
185 className="drag-region h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 wco:pr-[var(--workspace-native-controls-inset)]"
186 />
187 ) : null}
188 <main className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
189 <div className="mx-auto grid min-h-full w-full max-w-5xl content-center gap-10 px-6 py-12 sm:grid-cols-[170px_minmax(0,1fr)] sm:gap-14 sm:px-10 lg:px-12">
190 <aside className="flex min-w-0 flex-col justify-between sm:min-h-72">
191 <nav
192 aria-label="Setup progress"
193 className="flex flex-wrap gap-x-5 gap-y-1 sm:flex-col sm:gap-1"
194 >
195 {ONBOARDING_STAGES.map((stage, index) => (
196 <div
197 key={stage}
198 aria-current={index === stageIndex ? "step" : undefined}
199 className={cn(
200 "flex min-h-9 items-center gap-2.5 text-sm",
201 index < stageIndex
202 ? "text-success-foreground"
203 : index === stageIndex
204 ? "text-foreground"
205 : "text-muted-foreground/60",
206 )}
207 >
208 {index < stageIndex ? (
209 <CheckIcon className="size-3.5" />
210 ) : (
211 <span className="w-3.5 font-mono text-xs">0{index + 1}</span>
212 )}
213 <span>{stage}</span>
214 </div>
215 ))}
216 </nav>
217 {targetEnvironment ? (
218 <div className="mt-8 hidden items-center gap-2 text-xs text-muted-foreground sm:flex">
219 <span className="size-1.5 rounded-full bg-success" />
220 <span className="truncate">{targetEnvironment.label}</span>
221 </div>
222 ) : null}
223 </aside>
180 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
181 <DialogPopup
182 className="max-w-xl overflow-x-hidden overflow-y-auto"
183 bottomStickOnMobile={false}
184 showCloseButton={false}
185 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
186 >
187 <DialogTitle className="sr-only">Set up T3 Code</DialogTitle>
188 <div className="flex min-h-0 flex-col">
189 <DialogHeader className="gap-4">
190 <div className="flex items-baseline gap-1.5" role="img" aria-label="T3 Code">
191 <T3Wordmark className="h-4 w-auto shrink-0" aria-hidden />
192 <span className="text-[1.4rem] font-medium tracking-tight text-muted-foreground">
193 Code
194 </span>
195 </div>
196 <WizardSteps
197 steps={ONBOARDING_STAGES}
198 currentStep={stageIndex}
199 isStepDisabled={(index) => isImporting || index >= stageIndex}
200 onStepChange={(index) => {
201 if (isImporting || index > stageIndex) return;
202 setStep(index === 0 ? "connection" : "agents");
203 }}
204 />
205 </DialogHeader>
224206
225 <section className="min-w-0">
207 <WizardPanel className="min-w-0" holdHeight={isLoadingProjects}>
226208 {step === "connection" ? (
227209 <ConnectionStep
228 localAvailable={localAvailable}
229 localLabel={targetEnvironment?.label ?? "This computer"}
230 onLocal={() => {
231 setMode("local");
232 setPairedEnvironmentId(null);
233 setStep("agents");
234 }}
235 onConnect={() => {
236 setMode("connect");
237 setPairedEnvironmentId(null);
238 setStep("connect-machines");
239 }}
240 onDirect={() => {
241 setMode("direct");
242 setPairedEnvironmentId(null);
243 setStep("pair-direct");
244 }}
245 />
246 ) : step === "connect-machines" ? (
247 <ConnectMachinesStep
248 onBack={() => setStep("connection")}
249 onContinue={() => setStep("agents")}
250 />
251 ) : step === "pair-direct" ? (
252 <PairDirectStep
253 onBack={() => setStep("connection")}
210 expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}
211 selectedIds={selectedIds}
212 autoSelectedComputers={autoSelectedComputers.current}
213 onSelectionChange={setSelection}
214 onToggleEnvironment={(environmentId, checked) =>
215 setSelection((current) => {
216 const next = new Set(current ?? selectedIds);
217 if (checked) next.add(environmentId);
218 else next.delete(environmentId);
219 return next;
220 })
221 }
222 onContinue={() =>
223 startSetup(
224 environments
225 .filter((environment) => selectedIds.has(environment.environmentId))
226 .map((environment) => environment.environmentId),
227 )
228 }
254229 onPaired={(environmentId) => {
255 setPairedEnvironmentId(environmentId);
256 setStep("agents");
230 setSelection(new Set([...selectedIds, environmentId]));
257231 }}
258232 />
259233 ) : step === "agents" ? (
260 <AgentsStep
261 mode={mode}
262 pairedEnvironmentId={pairedEnvironmentId}
263 onBack={() =>
264 setStep(
265 mode === "local"
266 ? "connection"
267 : mode === "connect"
268 ? "connect-machines"
269 : "pair-direct",
270 )
271 }
272 onContinue={() => setStep("import")}
273 onSkip={() => setStep("import")}
274 />
234 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
275235 ) : (
276236 <ImportStep
277 mode={mode}
278 pairedEnvironmentId={pairedEnvironmentId}
279 onBack={() => setStep("agents")}
237 scans={scans}
238 isImporting={isImporting}
239 setIsImporting={setIsImporting}
280240 onDone={finish}
281241 />
282242 )}
283 </section>
243 </WizardPanel>
284244 </div>
285 </main>
286 </div>
245 </DialogPopup>
246 </Dialog>
287247 );
288248}
289249
290250// ── Step 1: connection choice ────────────────────────────────
291251
292252function ConnectionStep({
293 localAvailable,
294 localLabel,
295 onLocal,
296 onConnect,
297 onDirect,
253 autoSelectedComputers,
254 expandPairingInitially,
255 selectedIds,
256 onSelectionChange,
257 onToggleEnvironment,
258 onContinue,
259 onPaired,
298260}: {
299 readonly localAvailable: boolean;
300 readonly localLabel: string;
301 readonly onLocal: () => void;
302 readonly onConnect: () => void;
303 readonly onDirect: () => void;
261 readonly autoSelectedComputers: Set<EnvironmentId>;
262 readonly expandPairingInitially: boolean;
263 readonly selectedIds: ReadonlySet<EnvironmentId>;
264 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId>) => void;
265 readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
266 readonly onContinue: () => void;
267 readonly onPaired: (environmentId: EnvironmentId) => void;
304268}) {
269 const { environments } = useEnvironments();
305270 const cloudEnabled = hasCloudPublicConfig();
306 const [choice, setChoice] = useState<"local" | "connect" | "direct">(
307 localAvailable ? "local" : cloudEnabled ? "connect" : "direct",
271 const directEnvironments = environments.filter(
272 (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment),
308273 );
309
310 const advance = () => {
311 if (choice === "local") onLocal();
312 else if (choice === "connect") onConnect();
313 else onDirect();
314 };
315
274 const [pairingOpen, setPairingOpen] = useState(expandPairingInitially);
275 const [isPairing, setIsPairing] = useState(false);
276 const ready =
277 selectedIds.size > 0 &&
278 [...selectedIds].every((id) =>
279 environments.some(
280 (environment) =>
281 environment.environmentId === id && environment.connection.phase === "connected",
282 ),
283 );
284 const continueRef = useRef<HTMLButtonElement>(null);
285 useEffect(() => {
286 if (
287 ready &&
288 (document.activeElement === document.body ||
289 document.activeElement?.getAttribute("role") === "dialog")
290 ) {
291 continueRef.current?.focus();
292 }
293 }, [ready]);
316294 return (
317295 <>
318 <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">Where is your code?</h1>
319 <p className="mt-2.5 text-sm text-muted-foreground">Choose where your agents will run.</p>
320 <div className="mt-8 border-t border-border">
321 {localAvailable ? (
322 <ConnectionOption
323 icon={MonitorIcon}
324 title="This computer"
325 description={localLabel}
326 truncateDescription
327 detail="No account"
328 selected={choice === "local"}
329 onSelect={() => setChoice("local")}
330 />
331 ) : null}
296 <h1 className="text-2xl font-semibold tracking-tight text-foreground">
297 Connect your computers
298 </h1>
299 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
300 Choose one or more computers. We’ll set up agents and projects on each.
301 </p>
302 {directEnvironments.length > 0 ? (
303 <fieldset className="mt-5 space-y-2">
304 <legend className="sr-only">Computers to set up</legend>
305 {directEnvironments.map((environment) => (
306 <label
307 key={environment.environmentId}
308 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-3"
309 >
310 <Checkbox
311 checked={selectedIds.has(environment.environmentId)}
312 onCheckedChange={(checked) => {
313 const next = new Set(selectedIds);
314 if (checked) next.add(environment.environmentId);
315 else next.delete(environment.environmentId);
316 onSelectionChange(next);
317 }}
318 />
319 <MonitorIcon className="size-4 shrink-0 text-muted-foreground" />
320 <span className="min-w-0 flex-1">
321 <span className="flex items-baseline justify-between gap-3">
322 <span className="min-w-0 text-sm font-medium break-words">
323 {environment.label}
324 </span>
325 <span className="shrink-0 text-xs text-muted-foreground">
326 {environment.connection.phase === "connected" ? "Connected" : "Connecting…"}
327 </span>
328 </span>
329 {environment.displayUrl ? (
330 <span className="mt-0.5 block text-xs break-all text-muted-foreground">
331 {environment.displayUrl}
332 </span>
333 ) : null}
334 </span>
335 </label>
336 ))}
337 </fieldset>
338 ) : null}
339 <div className="mt-4 space-y-2">
332340 {cloudEnabled ? (
333 <ConnectionOption
334 icon={CloudIcon}
335 title="T3 Connect"
336 description="Your computers, wherever you are"
337 detail="Sign in"
338 selected={choice === "connect"}
339 onSelect={() => setChoice("connect")}
341 <ConnectAccountOption
342 autoSelectedComputers={autoSelectedComputers}
343 disabled={isPairing}
344 selectedIds={selectedIds}
345 onToggleEnvironment={onToggleEnvironment}
340346 />
341347 ) : null}
342 <ConnectionOption
343 icon={LinkIcon}
344 title="Pair a server"
345 description="Local network or Tailscale"
346 detail="Pairing link"
347 selected={choice === "direct"}
348 onSelect={() => setChoice("direct")}
349 />
348 <Collapsible
349 open={pairingOpen}
350 onOpenChange={setPairingOpen}
351 className="rounded-lg border border-border bg-background"
352 >
353 <CollapsibleTrigger
354 disabled={isPairing}
355 render={
356 <Button
357 variant="ghost"
358 className="h-auto min-h-14 w-full justify-start gap-3 px-3 py-3 text-left whitespace-normal sm:h-auto"
359 />
360 }
361 >
362 <LinkIcon className="size-4 text-muted-foreground" />
363 <span className="flex-1">Add a computer</span>
364 <ChevronRightIcon
365 className={cn("size-4 text-muted-foreground", pairingOpen && "rotate-90")}
366 />
367 </CollapsibleTrigger>
368 <CollapsiblePanel>
369 <div className="px-3 pb-3">
370 <PairingForm
371 isPairing={isPairing}
372 setIsPairing={setIsPairing}
373 onPaired={(environmentId) => {
374 setPairingOpen(false);
375 onPaired(environmentId);
376 requestAnimationFrame(() => continueRef.current?.focus());
377 }}
378 />
379 </div>
380 </CollapsiblePanel>
381 </Collapsible>
350382 </div>
351 <div className="mt-7 flex justify-end">
352 <Button className="gap-2" onClick={advance}>
383 <div className="mt-6 flex items-center justify-end gap-3">
384 <Button
385 ref={continueRef}
386 autoFocus={!expandPairingInitially}
387 disabled={!ready || isPairing}
388 onClick={onContinue}
389 >
353390 Continue
354391 <ArrowRightIcon className="size-3.5" />
355392 </Button>
......@@ -358,203 +395,108 @@ function ConnectionStep({
358395 );
359396}
360397
361function ConnectionOption({
362 icon: Icon,
363 title,
364 description,
365 truncateDescription = false,
366 detail,
367 selected,
368 onSelect,
398function ConnectAccountOption({
399 autoSelectedComputers,
400 disabled,
401 selectedIds,
402 onToggleEnvironment,
369403}: {
370 readonly icon: LucideIcon;
371 readonly title: string;
372 readonly description: string;
373 readonly truncateDescription?: boolean;
374 readonly detail: string;
375 readonly selected: boolean;
376 readonly onSelect: () => void;
404 readonly autoSelectedComputers: Set<EnvironmentId>;
405 readonly disabled: boolean;
406 readonly selectedIds: ReadonlySet<EnvironmentId>;
407 readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
377408}) {
378 return (
379 <button
380 type="button"
381 aria-pressed={selected}
382 onClick={onSelect}
383 className={cn(
384 "group flex min-h-20 w-full cursor-pointer items-center gap-4 border-b border-border px-1 py-3 text-left transition-colors",
385 "outline-none focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-ring",
386 selected
387 ? "text-foreground"
388 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
389 )}
390 >
391 <Icon
392 className={cn("size-[18px]", selected ? "text-success-foreground" : "text-icon-muted")}
393 />
394 <span className="min-w-0 flex-1">
395 <span className="block text-sm font-medium">{title}</span>
396 <span
397 className={cn(
398 "mt-1 block text-xs text-muted-foreground",
399 truncateDescription ? "truncate" : "break-words whitespace-normal",
400 )}
401 >
402 {description}
403 </span>
404 </span>
405 <span className="hidden text-xs text-muted-foreground sm:block">{detail}</span>
406 {selected ? (
407 <CheckIcon className="size-4 text-success-foreground" />
408 ) : (
409 <span className="size-4" />
410 )}
411 </button>
412 );
413}
414
415// ── Step 2: T3 Connect (sign in, then connect machines) ──────
416
417const CONNECT_LOGIN_COMMAND = "npx t3 connect";
418
419/**
420 * Sign-in and machine-connection combined: signed out shows the Clerk prompt,
421 * signed in forks on account state — zero connected machines blocks on the
422 * `npx t3 connect` command and auto-advance is left to the user pressing
423 * Continue once their machine appears; existing machines show a confirmation
424 * list with the command folded away. There is deliberately no "primary
425 * machine" selection.
426 */
427function ConnectMachinesStep({
428 onBack,
429 onContinue,
430}: {
431 readonly onBack: () => void;
432 readonly onContinue: () => void;
433}) {
434 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read
435 // as signed-out mid-transition.
409 const { environments } = useEnvironments();
436410 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
437411 const { openAuthPrompt } = useT3ConnectAuthPrompt();
438 const { environments } = useEnvironments();
439 const primaryEnvironment = usePrimaryEnvironment();
440 const savedEnvironments = environments.filter(isOnboardingRelayEnvironment);
441 // Only a live connection counts: a saved-but-offline machine must not show
442 // the "connected" confirmation (the agents step would find nothing to
443 // probe). Its row still renders in the list either way.
444 const hasRemoteMachines = savedEnvironments.some(
445 (environment) => environment.connection.phase === "connected",
446 );
447
448 if (!isLoaded) {
449 return <StepShell title="Sign in to T3 Connect" onBack={onBack} />;
450 }
451
452 if (!isSignedIn) {
453 return (
454 <StepShell
455 title="Sign in to T3 Connect"
456 description="Connect your computers with one account."
457 onBack={onBack}
458 >
459 <div className="mt-6">
460 <Button onClick={openAuthPrompt}>Sign in</Button>
461 </div>
462 </StepShell>
463 );
464 }
412 const [expanded, setExpanded] = useState(true);
413 const [discoveryReady, setDiscoveryReady] = useState(false);
414 const onDiscoveryReady = useCallback(() => setDiscoveryReady(true), []);
465415
466416 return (
467 <StepShell
468 title={hasRemoteMachines ? "Your computers" : "Connect your computer"}
469 description={
470 hasRemoteMachines
471 ? "Connected to your T3 account."
472 : "Run this command on the computer with your code."
473 }
474 onBack={onBack}
417 <Collapsible
418 open={expanded && !!isSignedIn && discoveryReady}
419 onOpenChange={setExpanded}
420 className="rounded-lg border border-border bg-background"
475421 >
476 {hasRemoteMachines ? (
477 <>
478 <div className="mt-6 overflow-hidden border-y border-border">
479 <CloudEnvironmentConnectRows
480 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
481 savedEnvironments={savedEnvironments}
482 showSavedEnvironments
483 empty={null}
484 />
485 </div>
486 <Collapsible className="mt-4">
487 <CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
488 <ChevronRightIcon className="size-3.5 transition-transform duration-200 group-data-panel-open:rotate-90" />
489 Add another machine
490 </CollapsibleTrigger>
491 <CollapsiblePanel>
492 <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-2" />
493 <p className="mt-2 text-xs text-muted-foreground">
494 Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "}
495 <code className="font-mono">npx t3 serve</code>.
496 </p>
497 </CollapsiblePanel>
498 </Collapsible>
499 <div className="mt-7 flex justify-end">
500 <Button onClick={onContinue}>Continue</Button>
422 <CollapsibleTrigger
423 disabled={disabled || !isLoaded}
424 onClick={(event) => {
425 if (!isSignedIn) {
426 event.preventDefault();
427 setExpanded(true);
428 openAuthPrompt();
429 }
430 }}
431 render={
432 <Button
433 variant="ghost"
434 className="h-auto min-h-14 w-full justify-start gap-3 px-3 py-3 text-left whitespace-normal sm:h-auto"
435 />
436 }
437 >
438 <CloudIcon className="size-4 text-muted-foreground" />
439 <span className="flex-1">T3 Connect</span>
440 <span className="text-xs text-muted-foreground">
441 {!isLoaded
442 ? "Loading sign-in…"
443 : !isSignedIn
444 ? "Sign in"
445 : !discoveryReady
446 ? "Loading computers…"
447 : null}
448 </span>
449 <ChevronRightIcon
450 className={cn("size-4 text-muted-foreground", expanded && isSignedIn && "rotate-90")}
451 />
452 </CollapsibleTrigger>
453 <CollapsiblePanel keepMounted>
454 <div className="px-3 pb-3">
455 <div className="mb-3 space-y-1.5">
456 {isSignedIn ? (
457 <CloudEnvironmentConnectRows
458 primaryEnvironmentId={null}
459 savedEnvironments={environments}
460 showSavedEnvironments
461 onDiscoveryReady={onDiscoveryReady}
462 selection={{ selectedIds, onChange: onToggleEnvironment, autoSelectedComputers }}
463 refreshWhileEmpty
464 empty={
465 <p className="py-3 text-sm text-muted-foreground">No computers linked yet.</p>
466 }
467 />
468 ) : null}
501469 </div>
502 </>
503 ) : (
504 <>
505 <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-7" prominent />
506 <p className="mt-2 text-xs text-muted-foreground">
507 Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "}
508 <code className="font-mono">npx t3 serve</code>.
470 <p className="text-sm text-muted-foreground">
471 Run this on each computer you want to connect.
509472 </p>
510 <div className="mt-5 overflow-hidden border-y border-border">
511 <CloudEnvironmentConnectRows
512 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
513 savedEnvironments={savedEnvironments}
514 showSavedEnvironments
515 refreshWhileEmpty
516 empty={
517 <p className="px-1 py-4 text-xs text-muted-foreground">
518 Waiting for your computer to connect.
519 </p>
520 }
521 />
522 </div>
523 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
524 <Button variant="ghost-muted" onClick={onContinue}>
525 Skip for now
526 </Button>
527 <div className="flex flex-wrap items-center justify-end gap-3">
528 <span className="hidden text-xs text-muted-foreground sm:block">
529 Waiting for connection
530 </span>
531 <Button disabled>Continue</Button>
532 </div>
533 </div>
534 </>
535 )}
536 </StepShell>
473 <CommandBlock command="npx t3 connect" className="mt-3" />
474 <p className="mt-3 text-xs text-muted-foreground">
475 Keep T3 Code running. Select the computers you want to set up above.
476 </p>
477 </div>
478 </CollapsiblePanel>
479 </Collapsible>
537480 );
538481}
539482
540483// ── Step 2′: Direct pairing ──────────────────────────────────
541484
542485/**
543 * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the
544 * server, paste the URL here. Registers the remote environment in this
545 * browser's catalog (same path the hosted /pair surface uses).
486 * Register a computer in this browser using a server-minted pairing link.
546487 */
547function PairDirectStep({
548 onBack,
488function PairingForm({
489 isPairing,
490 setIsPairing,
549491 onPaired,
550492}: {
551 readonly onBack: () => void;
493 readonly isPairing: boolean;
494 readonly setIsPairing: (value: boolean) => void;
552495 readonly onPaired: (environmentId: EnvironmentId) => void;
553496}) {
554497 const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false });
555498 const [pairingUrl, setPairingUrl] = useState("");
556499 const [errorMessage, setErrorMessage] = useState("");
557 const [isPairing, setIsPairing] = useState(false);
558500 const mountedRef = useRef(true);
559501
560502 useEffect(() => {
......@@ -565,9 +507,10 @@ function PairDirectStep({
565507 }, []);
566508
567509 const submit = async () => {
510 if (isPairing || pairingUrl.trim().length === 0) return;
568511 setIsPairing(true);
569512 setErrorMessage("");
570 const result = await connectPairingEnvironment({ pairingUrl });
513 const result = await connectPairingEnvironment({ pairingUrl: pairingUrl.trim() });
571514 if (!mountedRef.current) return;
572515 setIsPairing(false);
573516 if (result._tag === "Success") {
......@@ -580,28 +523,23 @@ function PairDirectStep({
580523 };
581524
582525 return (
583 <StepShell
584 title="Pair a server"
585 description="Connect with a one-time pairing link."
586 onBack={onBack}
587 >
588 <div className="mt-7 space-y-5">
589 <div>
590 <p className="text-sm text-muted-foreground">
591 <span className="font-mono text-muted-foreground/70">01</span> Run this on your server
592 </p>
593 <CommandBlock command="npx t3 pair" className="mt-2" />
594 <p className="mt-2 text-xs text-muted-foreground">
595 Start the server with <code className="font-mono">npx t3 serve</code> first. Add{" "}
596 <code className="font-mono">--tailscale</code> to use your tailnet.
597 </p>
598 </div>
526 <>
527 <form
528 className="space-y-3"
529 onSubmit={(event) => {
530 event.preventDefault();
531 void submit();
532 }}
533 >
599534 <div>
600535 <label className="block text-sm text-muted-foreground" htmlFor="onboarding-pairing-url">
601 <span className="font-mono text-muted-foreground/70">02</span> Paste the pairing link
536 Pairing link
602537 </label>
603538 <Input
604539 id="onboarding-pairing-url"
540 autoFocus
541 aria-invalid={errorMessage.length > 0}
542 aria-describedby={errorMessage ? "onboarding-pairing-error" : undefined}
605543 className="mt-2"
606544 size="lg"
607545 autoCapitalize="none"
......@@ -609,31 +547,55 @@ function PairDirectStep({
609547 autoCorrect="off"
610548 spellCheck={false}
611549 nativeInput
612 disabled={isPairing}
550 readOnly={isPairing}
613551 placeholder="https://your-server:5230/pair#token=…"
614552 value={pairingUrl}
615553 onChange={(event) => setPairingUrl(event.currentTarget.value)}
616554 onKeyDown={(event) => {
617 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
618 if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit();
555 if (
556 event.key === "Enter" &&
557 (event.nativeEvent.isComposing || event.keyCode === 229)
558 ) {
559 event.preventDefault();
560 }
619561 }}
620562 />
621563 </div>
622564 {errorMessage ? (
623 <div className="rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive">
565 <div
566 id="onboarding-pairing-error"
567 role="alert"
568 className="rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive"
569 >
624570 {errorMessage}
625571 </div>
626572 ) : null}
627 </div>
628 <div className="mt-7 flex justify-end">
629 <Button
630 disabled={isPairing || pairingUrl.trim().length === 0}
631 onClick={() => void submit()}
632 >
633 {isPairing ? "Pairing..." : "Connect"}
634 </Button>
635 </div>
636 </StepShell>
573 <Collapsible>
574 <div className="flex items-center justify-between gap-3">
575 <CollapsibleTrigger
576 type="button"
577 className="group flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
578 >
579 <ChevronRightIcon className="size-3.5 group-data-panel-open:rotate-90" />
580 Need a pairing link?
581 </CollapsibleTrigger>
582 <Button type="submit" disabled={isPairing || pairingUrl.trim().length === 0}>
583 {isPairing ? "Pairing..." : "Pair"}
584 </Button>
585 </div>
586 <CollapsiblePanel className="pt-3">
587 <p className="text-sm text-muted-foreground">
588 Run this on the computer with your code.
589 </p>
590 <CommandBlock command="npx t3 pair" className="mt-2" />
591 <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
592 Start T3 Code first, or run <code className="font-mono">npx t3 serve</code>. Add{" "}
593 <code className="font-mono">--tailscale</code> to use your tailnet.
594 </p>
595 </CollapsiblePanel>
596 </Collapsible>
597 </form>
598 </>
637599 );
638600}
639601
......@@ -660,58 +622,48 @@ interface AgentTerminalSession {
660622 * login that follows.
661623 */
662624function AgentsStep({
663 mode,
664 pairedEnvironmentId,
665 onBack,
625 environmentIds,
666626 onContinue,
667 onSkip,
668627}: {
669 readonly mode: ConnectionMode;
670 readonly pairedEnvironmentId: EnvironmentId | null;
671 readonly onBack: () => void;
628 readonly environmentIds: readonly EnvironmentId[];
672629 readonly onContinue: () => void;
673 readonly onSkip: () => void;
674630}) {
675 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
676 if (targetEnvironment === null) {
677 return (
678 <StepShell
679 title="Your agents"
680 description="Waiting for this computer to connect."
681 onBack={onBack}
631 const { environments } = useEnvironments();
632 return (
633 <StepShell title="Your agents" description="Agents available on your selected computers.">
634 <ScrollArea
635 scrollFade
636 className="mt-5 h-auto max-h-96 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
682637 >
683 <div className="mt-6 flex justify-end">
684 <Button variant="ghost-muted" onClick={onSkip}>
685 Skip for now
686 </Button>
638 <div className="space-y-5 pr-3">
639 {environmentIds.map((environmentId) => (
640 <ConnectedAgentsStep
641 key={environmentId}
642 environmentId={environmentId}
643 machineLabel={
644 environments.find((environment) => environment.environmentId === environmentId)
645 ?.label ?? "Computer"
646 }
647 />
648 ))}
687649 </div>
688 </StepShell>
689 );
690 }
691 return (
692 <ConnectedAgentsStep
693 key={targetEnvironment.environmentId}
694 environmentId={targetEnvironment.environmentId}
695 machineLabel={targetEnvironment.label}
696 onBack={onBack}
697 onContinue={onContinue}
698 onSkip={onSkip}
699 />
650 </ScrollArea>
651 <div className="mt-6 flex justify-end">
652 <Button autoFocus onClick={onContinue}>
653 Continue
654 <ArrowRightIcon className="size-3.5" />
655 </Button>
656 </div>
657 </StepShell>
700658 );
701659}
702660
703661function ConnectedAgentsStep({
704662 environmentId,
705663 machineLabel,
706 onBack,
707 onContinue,
708 onSkip,
709664}: {
710665 readonly environmentId: EnvironmentId;
711666 readonly machineLabel: string;
712 readonly onBack: () => void;
713 readonly onContinue: () => void;
714 readonly onSkip: () => void;
715667}) {
716668 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
717669 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
......@@ -732,12 +684,10 @@ function ConnectedAgentsStep({
732684 driver,
733685 provider: byDriver.get(driver),
734686 }));
735 const readyCount = primaryAgents.filter(
736 ({ provider }) => getOnboardingProviderState(provider) === "ready",
737 ).length;
738687 return (
739 <StepShell title="Your agents" description={`Detected on ${machineLabel}.`} onBack={onBack}>
740 <div className="mt-7 border-t border-border">
688 <section>
689 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>
690 <div className="space-y-1.5">
741691 {primaryAgents.map(({ driver, provider }) => (
742692 <AgentCard
743693 key={driver}
......@@ -778,21 +728,7 @@ function ConnectedAgentsStep({
778728 }}
779729 />
780730 ) : null}
781 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
782 <Button variant="ghost-muted" onClick={onSkip}>
783 Skip
784 </Button>
785 <div className="flex flex-wrap items-center justify-end gap-3">
786 <span className="text-xs text-muted-foreground">
787 {readyCount} of {primaryAgents.length} ready
788 </span>
789 <Button className="gap-2" onClick={onContinue}>
790 Continue
791 <ArrowRightIcon className="size-3.5" />
792 </Button>
793 </div>
794 </div>
795 </StepShell>
731 </section>
796732 );
797733}
798734
......@@ -816,13 +752,13 @@ function AgentCard({
816752 const providerState = getOnboardingProviderState(provider);
817753
818754 return (
819 <div className="flex min-h-20 items-center gap-4 border-b border-border py-3">
755 <div className="flex items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5">
820756 {Icon ? (
821 <Icon className={cn("size-6 shrink-0", driver !== "claudeAgent" && "fill-foreground")} />
757 <Icon className={cn("size-5 shrink-0", driver !== "claudeAgent" && "fill-foreground")} />
822758 ) : null}
823759 <div className="min-w-0 flex-1">
824760 <span className="block text-sm font-medium text-foreground">{displayName}</span>
825 <p className="mt-1 truncate text-xs text-muted-foreground">
761 <p className="mt-0.5 text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground">
826762 {summary.headline}
827763 {summary.detail ? ` · ${summary.detail}` : ""}
828764 </p>
......@@ -1008,35 +944,22 @@ function AgentInstallTerminal({
1008944
1009945// ── Step 4: import ───────────────────────────────────────────
1010946
1011/**
1012 * One-decision import (4B): a summary line with Import recent / Choose /
1013 * Skip. The default imports only projects touched in the last 30 days;
1014 * Choose expands a checklist including older ones. Imported projects also
1015 * receive Codex and Claude threads active within the last 30 days.
1016 */
1017947function ImportStep({
1018 mode,
1019 pairedEnvironmentId,
1020 onBack,
948 scans,
949 isImporting,
950 setIsImporting,
1021951 onDone,
1022952}: {
1023 readonly mode: ConnectionMode;
1024 readonly pairedEnvironmentId: EnvironmentId | null;
1025 readonly onBack: () => void;
953 readonly scans: ReturnType<typeof useProjectScans>;
954 readonly isImporting: boolean;
955 readonly setIsImporting: (value: boolean) => void;
1026956 readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>;
1027957}) {
1028 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
1029 const environmentId = targetEnvironment?.environmentId ?? null;
1030 const machineLabel = targetEnvironment?.label ?? "this machine";
1031 const scan = useEnvironmentQuery(
1032 environmentId === null ? null : agentSessionScan({ environmentId, input: {} }),
1033 );
958 const { environments } = useEnvironments();
1034959 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
1035960 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
1036961 const projects = useProjects();
1037 const [choosing, setChoosing] = useState(false);
1038 const [deselected, setDeselected] = useState<ReadonlySet<string>>(new Set());
1039 const [isImporting, setIsImporting] = useState(false);
962 const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
1040963 const [importError, setImportError] = useState("");
1041964 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
1042965 // Keep project creation attempts separate from completed history imports so both can retry.
......@@ -1048,27 +971,17 @@ function ImportStep({
1048971 );
1049972 const importGenerationRef = useRef(0);
1050973
1051 // Candidate paths are per-environment; a target switch would otherwise
1052 // leave stale entries in the deselection set (and stale success records).
974 // Ignore command completions after leaving the import step.
1053975 useEffect(() => {
1054976 importGenerationRef.current += 1;
1055 setDeselected(new Set());
1056 setIsImporting(false);
1057 setImportError("");
1058 setLandingProject(null);
1059 importedProjectsRef.current = new Map();
1060 projectsWithImportedHistoryRef.current = new Map();
1061 lastImportSelectionRef.current = [];
1062 projectAttemptsRef.current = new Map();
1063977 return () => {
1064978 importGenerationRef.current += 1;
1065979 };
1066 }, [environmentId]);
980 }, []);
1067981
1068982 useEffect(() => {
1069983 if (
1070984 landingProject !== null &&
1071 landingProject.environmentId === environmentId &&
1072985 projects.some(
1073986 (project) =>
1074987 project.id === landingProject.projectId &&
......@@ -1080,19 +993,26 @@ function ImportStep({
1080993 if (!completed) setIsImporting(false);
1081994 });
1082995 }
1083 }, [environmentId, landingProject, onDone, projects]);
996 }, [landingProject, onDone, projects, setIsImporting]);
1084997
1085998 const { available: candidates, recent } = useMemo(
1086 () => partitionOnboardingProjects(scan.data?.candidates ?? []),
1087 [scan.data],
999 () =>
1000 partitionOnboardingProjects(
1001 scans.flatMap((scan) =>
1002 (scan.data?.candidates ?? []).map((candidate) => ({
1003 ...candidate,
1004 environmentId: scan.environmentId,
1005 key: onboardingProjectKey(scan.environmentId, candidate.path),
1006 })),
1007 ),
1008 ),
1009 [scans],
1010 );
1011 const selected = candidates.filter((candidate) =>
1012 selectedPaths
1013 ? selectedPaths.has(candidate.key)
1014 : recent.some((item) => item.key === candidate.key),
10881015 );
1089 const more = candidates.length - recent.length;
1090 const scanTruncated = scan.data?.truncated === true;
1091 const scanLimitNotice = scanTruncated ? (
1092 <p className="mt-3 text-sm text-muted-foreground" role="status">
1093 {SCAN_LIMIT_MESSAGE}
1094 </p>
1095 ) : null;
10961016
10971017 const finishAfterImport = () => {
10981018 const projectRef = resolveOnboardingLandingProject(
......@@ -1108,14 +1028,15 @@ function ImportStep({
11081028 setLandingProject(projectRef);
11091029 };
11101030
1111 const runImport = async (selection: ReadonlyArray<AgentSessionProjectCandidate>) => {
1112 if (environmentId === null || selection.length === 0) {
1031 const runImport = async (selection: typeof candidates) => {
1032 if (isImporting) return;
1033 if (selection.length === 0) {
11131034 void onDone();
11141035 return;
11151036 }
11161037 setIsImporting(true);
11171038 setImportError("");
1118 lastImportSelectionRef.current = selection.map((candidate) => candidate.path);
1039 lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
11191040 const importGeneration = importGenerationRef.current;
11201041 const importedProjects = importedProjectsRef.current;
11211042 const projectAttempts = projectAttemptsRef.current;
......@@ -1126,29 +1047,30 @@ function ImportStep({
11261047 // duplicate-root invariant and read as a failure).
11271048 let importedProjectsCount =
11281049 importedProjects.size > 0
1129 ? selection.filter((candidate) => importedProjects.has(candidate.path)).length
1050 ? selection.filter((candidate) => importedProjects.has(candidate.key)).length
11301051 : 0;
11311052 let importedThreadCount = 0;
11321053 let skippedThreadCount = 0;
1133 let shouldRefreshScan = false;
1054 const refreshEnvironments = new Set<EnvironmentId>();
11341055 for (const candidate of selection) {
1056 const { environmentId } = candidate;
11351057 if (
11361058 importGeneration !== importGenerationRef.current ||
11371059 importedProjects !== importedProjectsRef.current
11381060 ) {
11391061 return;
11401062 }
1141 if (importedProjects.has(candidate.path)) continue;
1063 if (importedProjects.has(candidate.key)) continue;
11421064 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
11431065 if (projectId === null) {
1144 let attempt = projectAttempts.get(candidate.path);
1066 let attempt = projectAttempts.get(candidate.key);
11451067 if (attempt === undefined) {
11461068 const nextProjectId = newProjectId();
11471069 attempt = {
11481070 projectId: nextProjectId,
11491071 commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),
11501072 };
1151 projectAttempts.set(candidate.path, attempt);
1073 projectAttempts.set(candidate.key, attempt);
11521074 }
11531075 projectId = attempt.projectId;
11541076 const result = await createProject({
......@@ -1170,8 +1092,8 @@ function ImportStep({
11701092 }
11711093 if (result._tag !== "Success") {
11721094 if (!isAtomCommandInterrupted(result)) {
1173 projectAttempts.delete(candidate.path);
1174 shouldRefreshScan = true;
1095 projectAttempts.delete(candidate.key);
1096 refreshEnvironments.add(environmentId);
11751097 }
11761098 continue;
11771099 }
......@@ -1192,20 +1114,22 @@ function ImportStep({
11921114 skippedThreadCount += threadImportResult.value.skippedCount;
11931115 if (threadImportResult.value.importedCount > 0) {
11941116 projectsWithImportedHistoryRef.current.set(
1195 candidate.path,
1117 candidate.key,
11961118 scopeProjectRef(environmentId, projectId),
11971119 );
11981120 }
11991121 if (threadImportResult.value.skippedCount === 0) {
12001122 importedProjectsCount += 1;
1201 importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId));
1123 importedProjects.set(candidate.key, scopeProjectRef(environmentId, projectId));
12021124 }
12031125 } else if (!isAtomCommandInterrupted(threadImportResult)) {
1204 projectAttempts.delete(candidate.path);
1205 shouldRefreshScan = true;
1126 projectAttempts.delete(candidate.key);
1127 refreshEnvironments.add(environmentId);
12061128 }
12071129 }
1208 if (shouldRefreshScan) scan.refresh();
1130 for (const scan of scans) {
1131 if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
1132 }
12091133 setIsImporting(false);
12101134 if (importedProjectsCount < selection.length) {
12111135 if (importedThreadCount > 0 && skippedThreadCount > 0) {
......@@ -1228,166 +1152,129 @@ function ImportStep({
12281152 finishAfterImport();
12291153 };
12301154
1231 if (environmentId === null || (scan.isPending && scan.data === null)) {
1232 return (
1233 <StepShell
1234 title="Your projects"
1235 description="Looking for projects from Claude Code and Codex."
1236 onBack={onBack}
1237 >
1238 <div className="mt-6 flex justify-end">
1239 <Button variant="ghost-muted" onClick={() => void onDone()}>
1240 Skip
1241 </Button>
1242 </div>
1243 </StepShell>
1244 );
1245 }
1246
1247 if (scan.error !== null || candidates.length === 0) {
1248 return (
1249 <StepShell
1250 title="Your projects"
1251 description={
1252 scan.error !== null
1253 ? "Could not check this computer for projects."
1254 : scanTruncated
1255 ? SCAN_LIMIT_MESSAGE
1256 : "No existing Claude Code or Codex projects found."
1257 }
1258 onBack={onBack}
1259 >
1260 {scan.error !== null ? (
1261 <p className="mt-3 text-xs text-muted-foreground">You can add projects later.</p>
1262 ) : null}
1263 <div className="mt-6 flex justify-end gap-2">
1264 {scan.error !== null ? (
1265 <Button variant="ghost" onClick={scan.refresh}>
1266 Retry
1267 </Button>
1268 ) : null}
1269 <Button
1270 variant={scan.error !== null || scanTruncated ? "ghost-muted" : "default"}
1271 onClick={() => void onDone()}
1272 >
1273 {scan.error !== null || scanTruncated ? "Skip" : "Start coding"}
1274 </Button>
1275 </div>
1276 </StepShell>
1277 );
1278 }
1279
1280 if (choosing) {
1281 const selected = candidates.filter((candidate) => !deselected.has(candidate.path));
1155 if (scans.every((scan) => scan.data === null) && scans.some((scan) => scan.isPending)) {
12821156 return (
1283 <StepShell
1284 title="Choose your projects"
1285 onBack={() => setChoosing(false)}
1286 backDisabled={isImporting}
1287 description={`${candidates.length} found on ${machineLabel}.`}
1288 >
1289 {scanLimitNotice}
1290 <div className="mt-6 max-h-72 overflow-x-hidden overflow-y-auto border-y border-border">
1291 {candidates.map((candidate) => (
1292 <label
1293 key={candidate.path}
1294 className="flex min-h-12 cursor-pointer items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0 hover:bg-accent/50"
1295 >
1296 <Checkbox
1297 checked={!deselected.has(candidate.path)}
1298 onCheckedChange={(checked) => {
1299 setDeselected((previous) => {
1300 const next = new Set(previous);
1301 if (checked === true) next.delete(candidate.path);
1302 else next.add(candidate.path);
1303 return next;
1304 });
1305 }}
1306 />
1307 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
1308 {candidate.path}
1309 </span>
1310 <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block">
1311 {candidate.sources.map(formatSource).join(", ")} · {candidate.threadCount}{" "}
1312 {candidate.threadCount === 1 ? "thread" : "threads"}
1313 {candidate.lastActiveAt
1314 ? ` · ${formatRelativeTimeLabel(candidate.lastActiveAt)}`
1315 : ""}
1316 </span>
1317 </label>
1318 ))}
1157 <div className="flex h-full min-h-40 flex-col">
1158 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Your projects</h1>
1159 <div className="flex flex-1 flex-col items-center justify-center gap-3 py-6">
1160 <Spinner className="size-5 text-muted-foreground" />
1161 <p className="text-center text-sm text-muted-foreground">
1162 Looking for projects from Claude Code and Codex…
1163 </p>
13191164 </div>
1320 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
1321 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
1322 <Button
1323 variant="ghost-muted"
1324 disabled={isImporting}
1325 onClick={importError ? finishAfterImport : () => void onDone()}
1326 >
1327 {importError ? "Continue without the rest" : "Skip"}
1328 </Button>
1329 <Button
1330 disabled={isImporting || selected.length === 0}
1331 onClick={() => void runImport(selected)}
1332 >
1333 {isImporting ? "Importing..." : `Import ${selected.length}`}
1165 <div className="flex justify-end">
1166 <Button variant="ghost-muted" onClick={() => void onDone()}>
1167 Do not import projects
13341168 </Button>
13351169 </div>
1336 </StepShell>
1170 </div>
13371171 );
13381172 }
13391173
13401174 return (
13411175 <StepShell
1342 title="Your recent projects"
1343 description={`${recent.length} ${recent.length === 1 ? "project" : "projects"} found on ${machineLabel}.${more > 0 ? ` ${more} more available.` : ""}`}
1344 onBack={onBack}
1345 backDisabled={isImporting}
1176 title="Choose your projects"
1177 description="Import projects and conversations from your selected computers."
13461178 >
1347 {scanLimitNotice}
1348 <div className="mt-6 border-y border-border">
1349 {recent.slice(0, 4).map((candidate) => (
1350 <div
1351 key={candidate.path}
1352 className="flex min-h-12 items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0"
1353 >
1354 <CheckIcon className="size-3.5 shrink-0 text-success-foreground" />
1355 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
1356 {candidate.path}
1357 </span>
1358 <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block">
1359 {candidate.sources.map(formatSource).join(", ")}
1360 </span>
1361 </div>
1362 ))}
1363 {recent.length > 4 ? (
1364 <p className="px-1 py-3 text-xs text-muted-foreground">
1365 {recent.length - 4} more projects
1366 </p>
1367 ) : null}
1368 </div>
1179 <ScrollArea
1180 scrollFade
1181 className="mt-5 h-auto max-h-80 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
1182 >
1183 <div className="space-y-5 pr-3">
1184 {scans.map((scan) => {
1185 const groupCandidates = candidates.filter(
1186 (candidate) => candidate.environmentId === scan.environmentId,
1187 );
1188 const label =
1189 environments.find((environment) => environment.environmentId === scan.environmentId)
1190 ?.label ?? "Computer";
1191 return (
1192 <fieldset
1193 key={scan.environmentId}
1194 className="min-w-0 space-y-1.5"
1195 disabled={isImporting}
1196 >
1197 <legend className="mb-2 text-sm font-medium">{label}</legend>
1198 {scan.isPending && scan.data === null ? (
1199 <div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
1200 <Spinner className="size-4" />
1201 Looking for projects…
1202 </div>
1203 ) : scan.error !== null ? (
1204 <div
1205 role="alert"
1206 className="flex items-center justify-between gap-3 text-sm text-muted-foreground"
1207 >
1208 <span>Could not check projects. {scan.error}</span>
1209 <Button variant="ghost" size="sm" onClick={scan.refresh}>
1210 Retry
1211 </Button>
1212 </div>
1213 ) : groupCandidates.length === 0 ? (
1214 <p className="py-2 text-sm text-muted-foreground">
1215 No existing Claude Code or Codex projects found.
1216 </p>
1217 ) : null}
1218 {scan.data?.truncated ? (
1219 <p className="text-xs text-muted-foreground" role="status">
1220 {SCAN_LIMIT_MESSAGE}
1221 </p>
1222 ) : null}
1223 {groupCandidates.map((candidate) => (
1224 <label
1225 key={candidate.key}
1226 className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-border bg-background px-2.5 py-2 has-disabled:cursor-default"
1227 >
1228 <Checkbox
1229 checked={selected.some((item) => item.key === candidate.key)}
1230 onCheckedChange={(checked) => {
1231 const next = new Set(selected.map((item) => item.key));
1232 if (checked) next.add(candidate.key);
1233 else next.delete(candidate.key);
1234 setSelectedPaths(next);
1235 }}
1236 />
1237 <Tooltip>
1238 <TooltipTrigger
1239 render={<span className="min-w-0 flex-1 truncate font-mono text-xs" />}
1240 >
1241 {candidate.path}
1242 </TooltipTrigger>
1243 <TooltipPopup className="max-w-96 break-all font-mono">
1244 {candidate.path}
1245 </TooltipPopup>
1246 </Tooltip>
1247 <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
1248 {candidate.sources
1249 .map((source) => (source === "claudeAgent" ? "Claude" : "Codex"))
1250 .join(", ")}{" "}
1251 · {candidate.threadCount} {candidate.threadCount === 1 ? "thread" : "threads"}
1252 </span>
1253 </label>
1254 ))}
1255 </fieldset>
1256 );
1257 })}
1258 </div>
1259 </ScrollArea>
13691260 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
1370 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">
1261 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">
13711262 <Button
13721263 variant="ghost-muted"
13731264 disabled={isImporting}
13741265 onClick={importError ? finishAfterImport : () => void onDone()}
13751266 >
1376 {importError ? "Continue without the rest" : "Skip"}
1267 {importError ? "Continue without the rest" : "Do not import projects"}
1268 </Button>
1269 <Button
1270 autoFocus
1271 disabled={isImporting || selected.length === 0}
1272 onClick={() => void runImport(selected)}
1273 >
1274 {isImporting
1275 ? "Importing…"
1276 : `Import ${selected.length} ${selected.length === 1 ? "project" : "projects"}`}
13771277 </Button>
1378 <div className="flex items-center gap-2">
1379 <Button variant="ghost" disabled={isImporting} onClick={() => setChoosing(true)}>
1380 Choose
1381 </Button>
1382 <Button
1383 disabled={isImporting || recent.length === 0}
1384 onClick={() => void runImport(recent)}
1385 >
1386 {isImporting
1387 ? "Importing..."
1388 : `Import ${recent.length} ${recent.length === 1 ? "project" : "projects"}`}
1389 </Button>
1390 </div>
13911278 </div>
13921279 </StepShell>
13931280 );
......@@ -1398,31 +1285,15 @@ function ImportStep({
13981285function StepShell({
13991286 title,
14001287 description,
1401 onBack,
1402 backDisabled = false,
14031288 children,
14041289}: {
14051290 readonly title: string;
14061291 readonly description?: string;
1407 readonly onBack?: () => void;
1408 readonly backDisabled?: boolean;
14091292 readonly children?: React.ReactNode;
14101293}) {
14111294 return (
14121295 <>
1413 {onBack ? (
1414 <Button
1415 className="mb-5 -ml-2"
1416 disabled={backDisabled}
1417 onClick={onBack}
1418 size="xs"
1419 variant="ghost-muted"
1420 >
1421 <ChevronLeftIcon className="size-3.5" />
1422 Back
1423 </Button>
1424 ) : null}
1425 <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">{title}</h1>
1296 <h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
14261297 {description ? (
14271298 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
14281299 ) : null}
......@@ -1447,7 +1318,7 @@ function CommandBlock({
14471318 return (
14481319 <div
14491320 className={cn(
1450 "flex items-center justify-between gap-3 border border-border bg-accent/50 font-mono",
1321 "flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono",
14511322 prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
14521323 className,
14531324 )}
......@@ -1467,7 +1338,3 @@ function CommandBlock({
14671338 </div>
14681339 );
14691340}
1470
1471function formatSource(source: "claudeAgent" | "codex"): string {
1472 return source === "claudeAgent" ? "Claude" : "Codex";
1473}
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+152-157
......@@ -29,7 +29,7 @@ import { RadioGroup } from "../ui/radio-group";
2929import { toastManager } from "../ui/toast";
3030import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
3131import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
32import { AnimatedHeight } from "../AnimatedHeight";
32import { WizardPanel } from "../ui/wizard";
3333import {
3434 ADD_PROVIDER_WIZARD_STEPS,
3535 resolveWizardNavigation,
......@@ -246,173 +246,168 @@ export function AddProviderInstanceDialog({
246246 />
247247 </DialogHeader>
248248
249 <div
250 data-slot="dialog-panel"
251 className="space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5"
252 >
253 <AnimatedHeight>
254 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
255 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
256 Driver
257 </div>
258 <RadioGroup
259 value={driver}
260 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
261 aria-labelledby="add-instance-driver-label"
262 className="grid grid-cols-1 gap-2 sm:grid-cols-2"
263 >
264 {DRIVER_OPTIONS.map((option) => {
265 const IconComponent = option.icon;
266 return (
267 <RadioPrimitive.Root
268 key={option.value}
269 value={option.value}
270 className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
271 >
272 <IconComponent className="size-4 shrink-0" aria-hidden />
273 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
274 {option.label}
275 </span>
276 <RadioPrimitive.Indicator
277 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
278 aria-hidden
279 >
280 <CheckIcon className="size-3.5 shrink-0" />
281 </RadioPrimitive.Indicator>
282 {option.badgeLabel ? (
283 <Badge variant="warning" size="sm">
284 {option.badgeLabel}
285 </Badge>
286 ) : null}
287 </RadioPrimitive.Root>
288 );
289 })}
290 {COMING_SOON_DRIVER_OPTIONS.map((option) => {
291 const IconComponent = option.icon;
292 return (
293 <RadioPrimitive.Root
294 key={option.value}
295 value={option.value}
296 disabled
297 className={cn(
298 "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
299 )}
249 <WizardPanel>
250 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
251 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
252 Driver
253 </div>
254 <RadioGroup
255 value={driver}
256 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
257 aria-labelledby="add-instance-driver-label"
258 className="grid grid-cols-1 gap-2 sm:grid-cols-2"
259 >
260 {DRIVER_OPTIONS.map((option) => {
261 const IconComponent = option.icon;
262 return (
263 <RadioPrimitive.Root
264 key={option.value}
265 value={option.value}
266 className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
267 >
268 <IconComponent className="size-4 shrink-0" aria-hidden />
269 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
270 {option.label}
271 </span>
272 <RadioPrimitive.Indicator
273 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
274 aria-hidden
300275 >
301 <IconComponent
302 className="size-4 shrink-0 text-muted-foreground"
303 aria-hidden
304 />
305 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
306 {option.label}
307 </span>
276 <CheckIcon className="size-3.5 shrink-0" />
277 </RadioPrimitive.Indicator>
278 {option.badgeLabel ? (
308279 <Badge variant="warning" size="sm">
309 Coming Soon
280 {option.badgeLabel}
310281 </Badge>
311 </RadioPrimitive.Root>
312 );
313 })}
314 </RadioGroup>
315 </div>
282 ) : null}
283 </RadioPrimitive.Root>
284 );
285 })}
286 {COMING_SOON_DRIVER_OPTIONS.map((option) => {
287 const IconComponent = option.icon;
288 return (
289 <RadioPrimitive.Root
290 key={option.value}
291 value={option.value}
292 disabled
293 className={cn(
294 "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
295 )}
296 >
297 <IconComponent
298 className="size-4 shrink-0 text-muted-foreground"
299 aria-hidden
300 />
301 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
302 {option.label}
303 </span>
304 <Badge variant="warning" size="sm">
305 Coming Soon
306 </Badge>
307 </RadioPrimitive.Root>
308 );
309 })}
310 </RadioGroup>
311 </div>
316312
317 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
318 <span className="text-xs font-medium text-foreground">Label</span>
319 <Input
320 className="bg-background"
321 placeholder="e.g. Work"
322 value={label}
323 onChange={(event) => setLabel(event.target.value)}
324 />
313 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
314 <span className="text-xs font-medium text-foreground">Label</span>
315 <Input
316 className="bg-background"
317 placeholder="e.g. Work"
318 value={label}
319 onChange={(event) => setLabel(event.target.value)}
320 />
321 <span className="text-[11px] text-muted-foreground">
322 Shown in the provider list. Optional.
323 </span>
324 </label>
325
326 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
327 <span className="text-xs font-medium text-foreground">Instance ID</span>
328 <Input
329 className="bg-background"
330 placeholder={`${driver}_work`}
331 value={instanceId}
332 onChange={(event) => {
333 setInstanceIdOverride(event.target.value);
334 }}
335 aria-invalid={showInstanceIdError}
336 />
337 {showInstanceIdError ? (
338 <span className="text-[11px] text-destructive">{instanceIdError}</span>
339 ) : (
325340 <span className="text-[11px] text-muted-foreground">
326 Shown in the provider list. Optional.
341 Routing key used by threads and sessions. Letters, digits, '-', or '_'.
327342 </span>
328 </label>
343 )}
344 </label>
329345
330 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
331 <span className="text-xs font-medium text-foreground">Instance ID</span>
332 <Input
333 className="bg-background"
334 placeholder={`${driver}_work`}
335 value={instanceId}
336 onChange={(event) => {
337 setInstanceIdOverride(event.target.value);
338 }}
339 aria-invalid={showInstanceIdError}
346 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
347 <span className="text-xs font-medium text-foreground">Accent color</span>
348 <div className="flex min-w-0 flex-wrap items-center gap-2">
349 <input
350 type="color"
351 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
352 onChange={(event) => setAccentColor(event.target.value)}
353 aria-label="Provider instance accent color"
354 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
340355 />
341 {showInstanceIdError ? (
342 <span className="text-[11px] text-destructive">{instanceIdError}</span>
343 ) : (
344 <span className="text-[11px] text-muted-foreground">
345 Routing key used by threads and sessions. Letters, digits, '-', or '_'.
346 </span>
347 )}
348 </label>
349
350 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
351 <span className="text-xs font-medium text-foreground">Accent color</span>
352 <div className="flex min-w-0 flex-wrap items-center gap-2">
353 <input
354 type="color"
355 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
356 onChange={(event) => setAccentColor(event.target.value)}
357 aria-label="Provider instance accent color"
358 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
359 />
360 <div className="flex flex-wrap gap-1.5">
361 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
362 const selected = accentColor.toLowerCase() === swatch;
363 return (
364 <button
365 key={swatch}
366 type="button"
367 className={cn(
368 "size-6 cursor-pointer rounded-full border transition",
369 selected
370 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
371 : "border-black/10 hover:scale-105 dark:border-white/20",
372 )}
373 style={{ backgroundColor: swatch }}
374 onClick={() => setAccentColor(swatch)}
375 aria-label={`Use ${swatch} accent`}
376 />
377 );
378 })}
379 </div>
380 {accentColor ? (
381 <Button
382 type="button"
383 size="xs"
384 variant="ghost"
385 className="text-muted-foreground"
386 onClick={() => setAccentColor("")}
387 >
388 Clear
389 </Button>
390 ) : null}
356 <div className="flex flex-wrap gap-1.5">
357 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
358 const selected = accentColor.toLowerCase() === swatch;
359 return (
360 <button
361 key={swatch}
362 type="button"
363 className={cn(
364 "size-6 cursor-pointer rounded-full border transition",
365 selected
366 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
367 : "border-black/10 hover:scale-105 dark:border-white/20",
368 )}
369 style={{ backgroundColor: swatch }}
370 onClick={() => setAccentColor(swatch)}
371 aria-label={`Use ${swatch} accent`}
372 />
373 );
374 })}
391375 </div>
392 <span className="text-[11px] text-muted-foreground">
393 Optional marker shown in the picker.
394 </span>
376 {accentColor ? (
377 <Button
378 type="button"
379 size="xs"
380 variant="ghost"
381 className="text-muted-foreground"
382 onClick={() => setAccentColor("")}
383 >
384 Clear
385 </Button>
386 ) : null}
395387 </div>
388 <span className="text-[11px] text-muted-foreground">
389 Optional marker shown in the picker.
390 </span>
391 </div>
396392
397 {driverSettingsFields.length > 0 ? (
398 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
399 <ProviderSettingsForm
400 definition={driverOption}
401 value={configDraft}
402 idPrefix={`add-provider-${driver}`}
403 variant="dialog"
404 onChange={setConfigDraft}
405 />
406 </div>
407 ) : wizardStep === 2 ? (
408 <div className="grid gap-2">
409 <p className="text-sm text-muted-foreground">
410 This driver has no required configuration. You can add the instance now.
411 </p>
412 </div>
413 ) : null}
414 </AnimatedHeight>
415 </div>
393 {driverSettingsFields.length > 0 ? (
394 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
395 <ProviderSettingsForm
396 definition={driverOption}
397 value={configDraft}
398 idPrefix={`add-provider-${driver}`}
399 variant="dialog"
400 onChange={setConfigDraft}
401 />
402 </div>
403 ) : wizardStep === 2 ? (
404 <div className="grid gap-2">
405 <p className="text-sm text-muted-foreground">
406 This driver has no required configuration. You can add the instance now.
407 </p>
408 </div>
409 ) : null}
410 </WizardPanel>
416411
417412 <DialogFooter variant="bare">
418413 <Button
apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx+2-1
......@@ -1,6 +1,7 @@
11import { Children, isValidElement, type ReactElement } from "react";
22import { describe, expect, it, vi } from "vite-plus/test";
33
4import { WizardSteps } from "../ui/wizard";
45import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic";
56import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
67
......@@ -25,7 +26,7 @@ function renderStepButtons(
2526 onNavigation,
2627 });
2728
28 return Children.toArray(header.props.children)
29 return Children.toArray(WizardSteps(header.props).props.children)
2930 .filter((child): child is ReactElement<StepListItemProps> => isValidElement(child))
3031 .map((item) => item.props.children);
3132}
apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx+13-51
......@@ -1,6 +1,4 @@
1import { CheckIcon } from "lucide-react";
2
3import { cn } from "../../lib/utils";
1import { WizardSteps } from "../ui/wizard";
42import {
53 ADD_PROVIDER_WIZARD_STEPS,
64 resolveWizardNavigation,
......@@ -21,53 +19,17 @@ export function AddProviderInstanceWizardSteps({
2119 onNavigation,
2220}: AddProviderInstanceWizardStepsProps) {
2321 return (
24 <ol
25 className="grid grid-cols-3 gap-1 rounded-xl bg-zinc-25 p-1 ring-1 ring-black/5 dark:bg-white/4 dark:ring-white/5"
26 role="list"
27 >
28 {ADD_PROVIDER_WIZARD_STEPS.map((step, index) => (
29 <li key={step} className="min-w-0">
30 <button
31 type="button"
32 className={cn(
33 "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left outline-none hover:bg-card focus-visible:ring-2 focus-visible:ring-ring max-sm:justify-center max-sm:px-2",
34 index === currentStep &&
35 "bg-card text-foreground shadow-xs ring-1 ring-black/5 hover:bg-card dark:shadow-none dark:ring-white/5",
36 )}
37 aria-current={index === currentStep ? "step" : undefined}
38 aria-label={`${step}, step ${index + 1}${index < currentStep && summaries[index] ? `, ${summaries[index]}` : ""}`}
39 onClick={() =>
40 onNavigation(
41 resolveWizardNavigation(currentStep, index, ADD_PROVIDER_WIZARD_STEPS.length, {
42 instanceIdError,
43 }),
44 )
45 }
46 >
47 <span
48 className={cn(
49 "grid size-5 shrink-0 place-items-center rounded-full text-sm font-medium ring-1",
50 index < currentStep
51 ? "bg-primary text-primary-foreground ring-primary"
52 : index === currentStep
53 ? "bg-primary/10 text-primary ring-primary/30"
54 : "bg-card text-muted-foreground ring-black/10 dark:bg-white/5 dark:ring-white/10",
55 )}
56 aria-hidden
57 >
58 {index < currentStep ? <CheckIcon className="size-4 shrink-0" /> : index + 1}
59 </span>
60 <span
61 className={cn(
62 "min-w-0 truncate text-sm font-medium max-sm:hidden",
63 index === currentStep ? "text-foreground" : "text-muted-foreground",
64 )}
65 >
66 {step}
67 </span>
68 </button>
69 </li>
70 ))}
71 </ol>
22 <WizardSteps
23 steps={ADD_PROVIDER_WIZARD_STEPS}
24 currentStep={currentStep}
25 summaries={summaries}
26 onStepChange={(requestedStep) =>
27 onNavigation(
28 resolveWizardNavigation(currentStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, {
29 instanceIdError,
30 }),
31 )
32 }
33 />
7234 );
7335}
apps/web/src/components/ui/wizard.tsx created+90
......@@ -0,0 +1,90 @@
1import { CheckIcon } from "lucide-react";
2import type { ComponentProps } from "react";
3
4import { cn } from "../../lib/utils";
5import { AnimatedHeight } from "../AnimatedHeight";
6
7export function WizardSteps({
8 steps,
9 currentStep,
10 summaries,
11 onStepChange,
12 isStepDisabled,
13}: {
14 readonly steps: readonly string[];
15 readonly currentStep: number;
16 readonly summaries?: readonly (string | null)[];
17 readonly isStepDisabled?: (step: number) => boolean;
18 readonly onStepChange?: (step: number) => void;
19}) {
20 const Step = onStepChange ? "button" : "div";
21 return (
22 <ol
23 className="grid auto-cols-fr grid-flow-col gap-1 rounded-xl bg-zinc-25 p-1 ring-1 ring-black/5 dark:bg-white/4 dark:ring-white/5"
24 role="list"
25 aria-label="Setup progress"
26 >
27 {steps.map((step, index) => (
28 <li key={step} className="min-w-0">
29 <Step
30 {...(onStepChange
31 ? { type: "button" as const, disabled: isStepDisabled?.(index) }
32 : {})}
33 className={cn(
34 "flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring max-sm:justify-center max-sm:px-2",
35 onStepChange &&
36 "cursor-pointer hover:bg-card disabled:cursor-default disabled:hover:bg-transparent",
37 index === currentStep &&
38 "bg-card text-foreground shadow-xs ring-1 ring-black/5 hover:bg-card dark:shadow-none dark:ring-white/5",
39 )}
40 aria-current={index === currentStep ? "step" : undefined}
41 aria-label={`${step}, step ${index + 1}${index < currentStep && summaries?.[index] ? `, ${summaries?.[index]}` : ""}`}
42 onClick={onStepChange ? () => onStepChange(index) : undefined}
43 >
44 <span
45 className={cn(
46 "grid size-5 shrink-0 place-items-center rounded-full text-sm font-medium ring-1",
47 index < currentStep
48 ? "bg-primary text-primary-foreground ring-primary"
49 : index === currentStep
50 ? "bg-primary/10 text-primary ring-primary/30"
51 : "bg-card text-muted-foreground ring-black/10 dark:bg-white/5 dark:ring-white/10",
52 )}
53 aria-hidden
54 >
55 {index < currentStep ? <CheckIcon className="size-4 shrink-0" /> : index + 1}
56 </span>
57 <span
58 className={cn(
59 "min-w-0 truncate text-sm font-medium max-sm:hidden",
60 index === currentStep ? "text-foreground" : "text-muted-foreground",
61 )}
62 >
63 {step}
64 </span>
65 </Step>
66 </li>
67 ))}
68 </ol>
69 );
70}
71
72export function WizardPanel({
73 className,
74 children,
75 holdHeight = false,
76 ...props
77}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) {
78 return (
79 <div
80 data-slot="dialog-panel"
81 className={cn(
82 "space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
83 className,
84 )}
85 {...props}
86 >
87 <AnimatedHeight holdHeight={holdHeight}>{children}</AnimatedHeight>
88 </div>
89 );
90}
apps/web/src/hooks/useTheme.test.ts-201
......@@ -204,204 +204,3 @@ describe("theme failure handling", () => {
204204 }
205205 });
206206});
207
208describe("onboarding theme", () => {
209 it("clears custom palettes and restores the latest selected theme", async () => {
210 const storage = createStorage();
211 const classes = new Set<string>();
212 const styleValues = new Map<string, string>();
213 const root = {
214 classList: {
215 add: (name: string) => classes.add(name),
216 contains: (name: string) => classes.has(name),
217 remove: (name: string) => classes.delete(name),
218 toggle: (name: string, force?: boolean) => {
219 const next = force ?? !classes.has(name);
220 if (next) classes.add(name);
221 else classes.delete(name);
222 return next;
223 },
224 },
225 dataset: {} as Record<string, string>,
226 offsetHeight: 0,
227 style: {
228 backgroundColor: "",
229 removeProperty: (name: string) => styleValues.delete(name),
230 setProperty: (name: string, value: string) => styleValues.set(name, value),
231 },
232 };
233 vi.doMock("react", () => ({
234 useCallback: <A>(callback: A) => callback,
235 useEffect: () => undefined,
236 useSyncExternalStore: (
237 subscribe: (listener: () => void) => () => void,
238 getSnapshot: () => unknown,
239 ) => {
240 subscribe(() => undefined);
241 return getSnapshot();
242 },
243 }));
244 vi.stubGlobal("window", {
245 addEventListener: () => undefined,
246 localStorage: storage,
247 matchMedia: () => ({
248 matches: false,
249 addEventListener: () => undefined,
250 removeEventListener: () => undefined,
251 }),
252 removeEventListener: () => undefined,
253 });
254 vi.stubGlobal("document", {
255 body: { style: { backgroundColor: "" } },
256 createElement: () => ({ name: "", setAttribute: () => undefined }),
257 documentElement: root,
258 head: { append: () => undefined },
259 querySelector: () => null,
260 querySelectorAll: () => [],
261 });
262 vi.stubGlobal("getComputedStyle", () => ({
263 backgroundColor: "rgb(0, 0, 0)",
264 getPropertyValue: () => "",
265 }));
266 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
267 callback(0);
268 return 0;
269 });
270
271 const { EMBER_THEME, installCustomTheme } = await import("../themePalette");
272 const firstTheme = installCustomTheme({
273 ...EMBER_THEME,
274 id: "first-custom",
275 label: "First Custom",
276 });
277 const secondTheme = installCustomTheme({
278 ...EMBER_THEME,
279 id: "second-custom",
280 label: "Second Custom",
281 colors: { ...EMBER_THEME.colors, error: "#123456" },
282 });
283 storage.setItem("t3code:theme", firstTheme.id);
284
285 const { mountOnboardingTheme, useTheme } = await import("./useTheme");
286 expect(root.dataset.themeId).toBe(firstTheme.id);
287 expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error);
288
289 const cleanup = mountOnboardingTheme();
290 expect(root.dataset.themeId).toBeUndefined();
291 expect(styleValues.size).toBe(0);
292
293 expect(useTheme().setTheme(secondTheme.id)).toBe(true);
294 expect(root.dataset.themeId).toBeUndefined();
295 expect(styleValues.size).toBe(0);
296
297 cleanup();
298 expect(root.dataset.themeId).toBe(secondTheme.id);
299 expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error);
300 });
301
302 it("follows the saved appearance and restores the latest saved theme", async () => {
303 const storage = createStorage();
304 storage.setItem("t3code:theme", "dark");
305 const classes = new Set<string>();
306 const styleValues = new Map<string, string>();
307 const style = {
308 backgroundColor: "",
309 removeProperty: (name: string) => styleValues.delete(name),
310 setProperty: (name: string, value: string) => styleValues.set(name, value),
311 };
312 const root = {
313 classList: {
314 add: (name: string) => classes.add(name),
315 contains: (name: string) => classes.has(name),
316 remove: (name: string) => classes.delete(name),
317 toggle: (name: string, force?: boolean) => {
318 const next = force ?? !classes.has(name);
319 if (next) classes.add(name);
320 else classes.delete(name);
321 return next;
322 },
323 },
324 dataset: {} as Record<string, string>,
325 offsetHeight: 0,
326 style,
327 };
328 const body = { style: { backgroundColor: "" } };
329 let storageHandler: ((event: StorageEvent) => void) | undefined;
330 const setDesktopTheme = vi.fn().mockResolvedValue(undefined);
331 vi.doMock("react", () => ({
332 useCallback: <A>(callback: A) => callback,
333 useEffect: () => undefined,
334 useSyncExternalStore: (
335 subscribe: (listener: () => void) => () => void,
336 getSnapshot: () => unknown,
337 ) => {
338 subscribe(() => undefined);
339 return getSnapshot();
340 },
341 }));
342 vi.stubGlobal("window", {
343 addEventListener: (type: string, listener: (event: StorageEvent) => void) => {
344 if (type === "storage") storageHandler = listener;
345 },
346 localStorage: storage,
347 matchMedia: () => ({
348 matches: false,
349 addEventListener: () => undefined,
350 removeEventListener: () => undefined,
351 }),
352 removeEventListener: () => undefined,
353 desktopBridge: { setTheme: setDesktopTheme },
354 });
355 vi.stubGlobal("document", {
356 body,
357 createElement: () => ({ name: "", setAttribute: () => undefined }),
358 documentElement: root,
359 head: { append: () => undefined },
360 querySelector: () => null,
361 querySelectorAll: () => [],
362 });
363 vi.stubGlobal("getComputedStyle", () => ({
364 backgroundColor:
365 root.dataset.onboardingSurface !== undefined && classes.has("dark")
366 ? "rgb(0, 0, 0)"
367 : classes.has("dark")
368 ? "rgb(10, 10, 10)"
369 : "rgb(255, 255, 255)",
370 getPropertyValue: () => "",
371 }));
372 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
373 callback(0);
374 return 0;
375 });
376
377 const { mountOnboardingTheme, useTheme } = await import("./useTheme");
378 expect(useTheme().resolvedTheme).toBe("dark");
379 const cleanup = mountOnboardingTheme();
380
381 // A dark preference pins the wizard to true black.
382 expect(root.dataset.onboardingSurface).toBe("");
383 expect(classes.has("dark")).toBe(true);
384 expect(root.style.backgroundColor).toBe("#000");
385 expect(body.style.backgroundColor).toBe("#000");
386 expect(useTheme().resolvedTheme).toBe("dark");
387 expect(setDesktopTheme).toHaveBeenLastCalledWith("dark");
388
389 // A light preference switches the wizard to the default light palette.
390 storage.setItem("t3code:theme", "light");
391 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
392 expect(classes.has("dark")).toBe(false);
393 expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)");
394 expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)");
395 expect(useTheme().resolvedTheme).toBe("light");
396 expect(setDesktopTheme).toHaveBeenLastCalledWith("light");
397
398 cleanup();
399 expect(root.dataset.onboardingSurface).toBeUndefined();
400 expect(classes.has("dark")).toBe(false);
401 expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)");
402 expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)");
403 expect(storage.getItem("t3code:theme")).toBe("light");
404 expect(useTheme().resolvedTheme).toBe("light");
405 expect(setDesktopTheme).toHaveBeenLastCalledWith("light");
406 });
407});
apps/web/src/hooks/useTheme.ts+6-55
......@@ -99,13 +99,6 @@ function themeHalvesSignature(halves: ThemeHalves | null): string {
9999 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
100100}
101101
102function isOnboardingThemeActive(): boolean {
103 return (
104 typeof document !== "undefined" &&
105 document.documentElement.dataset?.onboardingSurface !== undefined
106 );
107}
108
109102const THEME_COLOR_META_NAME = "theme-color";
110103const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
111104
......@@ -300,22 +293,15 @@ function resolveBrowserChromeSurface(): HTMLElement {
300293
301294export function syncBrowserChromeTheme() {
302295 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
303 const onboardingActive = isOnboardingThemeActive();
304296 const rootStyles = getComputedStyle(document.documentElement);
305 const themeChromeColor =
306 !onboardingActive && document.documentElement.dataset.themeId
307 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
308 : null;
297 const themeChromeColor = document.documentElement.dataset.themeId
298 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
299 : null;
309300 const surfaceColor = normalizeThemeColor(
310301 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
311302 );
312303 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
313 // Dark onboarding pins a true-black canvas; light onboarding uses the
314 // default light palette and reads it back from the document like the app.
315 const backgroundColor =
316 onboardingActive && document.documentElement.classList.contains("dark")
317 ? "#000"
318 : (themeChromeColor ?? surfaceColor ?? fallbackColor);
304 const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;
319305 if (!backgroundColor) return;
320306
321307 document.documentElement.style.backgroundColor = backgroundColor;
......@@ -336,13 +322,8 @@ export function syncBrowserChromeTheme() {
336322
337323function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {
338324 if (typeof document === "undefined" || typeof window === "undefined") return;
339 const onboardingActive = isOnboardingThemeActive();
340325 // Keep the editor's draft visible until an explicit refresh restores the selection.
341 if (
342 preservePreview &&
343 !onboardingActive &&
344 document.documentElement.dataset?.themeId === THEME_PREVIEW_ID
345 ) {
326 if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) {
346327 return;
347328 }
348329 const appearanceMode = readAppearanceModePreference(theme);
......@@ -370,11 +351,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
370351 appearanceMode,
371352 themeHalves,
372353 );
373 // Onboarding follows the saved light/dark appearance but never applies a
374 // custom palette, so the wizard keeps its fixed neutral tokens.
375 if (!onboardingActive) {
376 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
377 }
354 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
378355 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
379356 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
380357 syncBrowserChromeTheme();
......@@ -388,32 +365,6 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
388365 }
389366}
390367
391/**
392 * Own the document-wide palette used by the first-run wizard and its portals.
393 * The wizard follows the saved light or dark appearance (and system changes)
394 * but drops any custom theme palette until the returned cleanup runs.
395 */
396export function mountOnboardingTheme(): () => void {
397 if (typeof document === "undefined" || typeof window === "undefined") return () => {};
398
399 const root = document.documentElement;
400 // "system" is a reserved id with no palette, so this clears theme variables.
401 applyThemePalette("system");
402 root.dataset.onboardingSurface = "";
403 lastAppliedTheme = null;
404 applyTheme(getStored(), { preservePreview: false });
405 emitChange();
406
407 return () => {
408 delete root.dataset.onboardingSurface;
409 root.style.backgroundColor = "";
410 document.body.style.backgroundColor = "";
411 lastAppliedTheme = null;
412 applyTheme(getStored(), { suppressTransitions: true, preservePreview: false });
413 emitChange();
414 };
415}
416
417368export async function syncDesktopThemePreference(
418369 bridge: DesktopThemeBridge,
419370 theme: Theme,
apps/web/src/index.css+13-39
......@@ -609,6 +609,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
609609}
610610
611611@layer components {
612 /* AnimatedHeight owns resizing for its contents. Nested collapsibles must
613 expose their final layout immediately, otherwise each animation frame
614 restarts the parent's transition and the dialog trails behind its content. */
615 [data-slot="animated-height"] [data-slot="collapsible-panel"] {
616 height: auto !important;
617 transition: none !important;
618 }
619
620 [data-slot="animated-height"] [data-slot="collapsible-panel"][data-closed] {
621 display: none;
622 }
623
612624 /* Each maintainer palette gives the same line art its own material: rose
613625 vellum, forest drafting paper, marine cyanotype, copper, and violet ink.
614626 These colors stay deliberately deep at the top edge so the white stage
......@@ -1184,43 +1196,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
11841196 --terminal-selection-background: var(--app-theme-terminal-selection-background);
11851197}
11861198
1187/* The first-run flow owns the whole document so portaled menus and tooltips
1188 use the same fixed palette as the wizard. Light onboarding uses the default
1189 light palette from :root above. Dark onboarding pins a true-black palette.
1190 This follows the theme mapping so saved custom themes cannot override it
1191 while onboarding is mounted. */
1192html[data-onboarding-surface]:root {
1193 @variant dark {
1194 color-scheme: dark;
1195 --accent: #262626;
1196 --accent-foreground: #fff;
1197 --appearance-contrast-target: #fff;
1198 --app-chrome-background: #000;
1199 --background: #000;
1200 --border: #262626;
1201 --card: #000;
1202 --card-foreground: #fff;
1203 --destructive: var(--color-red-400);
1204 --foreground: #fff;
1205 --icon-muted: #a1a1aa;
1206 --input: #262626;
1207 --muted: #171717;
1208 --muted-foreground: #a1a1aa;
1209 --placeholder: #71717a;
1210 --popover: #171717;
1211 --popover-foreground: #fff;
1212 --ring: #737373;
1213 --secondary: #171717;
1214 --secondary-foreground: #fff;
1215 --secondary-label: #a1a1aa;
1216 --success-foreground: var(--color-emerald-400);
1217 --terminal-background: #000;
1218 --terminal-cursor: #fff;
1219 --terminal-foreground: #fff;
1220 --terminal-selection-background: rgb(255 255 255 / 20%);
1221 }
1222}
1223
12241199/* Theme-token dependency probes are restored synchronously, before paint. Keep
12251200 transitions from observing the temporary sentinel color in between. */
12261201html[data-theme-token-probe],
......@@ -1424,8 +1399,7 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] {
14241399
14251400/* Recompute contrast wherever a subtree owns its own semantic color palette. */
14261401:root,
1427[data-app-sidebar],
1428[data-onboarding-surface] {
1402[data-app-sidebar] {
14291403 --contrast-toolbar-foreground: color-mix(
14301404 in oklab,
14311405 color-mix(
apps/web/src/onboarding/projectImport.logic.test.ts+21
......@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33
44import {
55 partitionOnboardingProjects,
6 onboardingProjectKey,
67 resolveOnboardingLandingProject,
78 resolveOnboardingProjectId,
89} from "./projectImport.logic";
......@@ -243,3 +244,23 @@ describe("resolveOnboardingLandingProject", () => {
243244 ).toBe("current");
244245 });
245246});
247
248describe("projects on multiple computers", () => {
249 it("keeps identical paths independent when selecting a landing project after partial imports", () => {
250 const first = onboardingProjectKey(EnvironmentId.make("first"), "/code/app");
251 const second = onboardingProjectKey(EnvironmentId.make("second"), "/code/app");
252 const completed = new Map([[first, "first-project"]]);
253 expect(completed.has(second)).toBe(false);
254 expect(resolveOnboardingLandingProject([second], completed, completed)).toBeUndefined();
255 completed.set(second, "second-project");
256 expect(resolveOnboardingLandingProject([second, first], completed, completed)).toBe(
257 "second-project",
258 );
259 });
260
261 it("preserves computer identity when choosing recent projects", () => {
262 const first = { ...candidate("/code/app"), environmentId: "first" };
263 const second = { ...candidate("/code/app"), environmentId: "second" };
264 expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]);
265 });
266});
apps/web/src/onboarding/projectImport.logic.ts+7-2
......@@ -4,8 +4,8 @@ import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3
44const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
55
66/** Existing projects still need their agent history imported, so every scan candidate is offered. */
7export function partitionOnboardingProjects(
8 candidates: ReadonlyArray<AgentSessionProjectCandidate>,
7export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
8 candidates: ReadonlyArray<T>,
99 now = Date.now(),
1010) {
1111 const cutoff = now - RECENT_PROJECT_WINDOW_MS;
......@@ -53,3 +53,8 @@ export function resolveOnboardingLandingProject<T>(
5353 }
5454 return undefined;
5555}
56
57/** Paths identify projects only within the computer that owns them. */
58export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {
59 return JSON.stringify([environmentId, path]);
60}
apps/web/src/onboarding/useProjectScans.ts created+31
......@@ -0,0 +1,31 @@
1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import * as Option from "effect/Option";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5import { useContext, useMemo } from "react";
6
7import { agentSessionScan } from "../state/agentSessions";
8import { formatEnvironmentQueryError } from "../state/query";
9
10/** Subscribe to each selected computer without coupling their failures or refreshes. */
11export function useProjectScans(environmentIds: readonly EnvironmentId[]) {
12 const registry = useContext(RegistryContext);
13 const scansAtom = useMemo(
14 () =>
15 Atom.make((get) =>
16 environmentIds.map((environmentId) => {
17 const atom = agentSessionScan({ environmentId, input: {} });
18 const result = get(atom);
19 return {
20 environmentId,
21 data: Option.getOrNull(AsyncResult.value(result)),
22 error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null,
23 isPending: result.waiting || result._tag === "Initial",
24 refresh: () => registry.refresh(atom),
25 };
26 }),
27 ),
28 [environmentIds, registry],
29 );
30 return useAtomValue(scansAtom);
31}
apps/web/src/routes/__root.tsx+14-6
......@@ -124,15 +124,23 @@ function RootRouteView() {
124124 );
125125 }
126126
127 // The welcome wizard is full-screen like /pair, but keeps toasts so its
128 // connect/import actions can report failures.
127 // Show onboarding over the workspace, keeping automatic thread navigation
128 // and other startup dialogs suspended until setup finishes.
129129 if (pathname === "/welcome") {
130130 return (
131131 <ToastProvider>
132 <DocumentTitleSync />
133 <ContrastAppearanceSync />
134 <FontAppearanceSync />
135 <Outlet />
132 <AnchoredToastProvider>
133 <DocumentTitleSync />
134 <ContrastAppearanceSync />
135 <EnvironmentThemeSync />
136 <GlassAppearanceSync />
137 <FontAppearanceSync />
138 <CommandPalette>
139 <AppSidebarLayout>
140 <Outlet />
141 </AppSidebarLayout>
142 </CommandPalette>
143 </AnchoredToastProvider>
136144 </ToastProvider>
137145 );
138146 }
apps/web/src/routes/_chat.index.tsx+2-31
......@@ -2,9 +2,9 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
22import { scopeProjectRef } from "@t3tools/client-runtime/environment";
33import { createFileRoute, Link } from "@tanstack/react-router";
44import { LinkIcon, PlusIcon } from "lucide-react";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { useEffect, useMemo, useRef, useState } from "react";
66
7import { openCommandPalette } from "../commandPaletteBus";
7import { NoProjectsHero } from "../components/NoProjectsHero";
88import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
99import { Button } from "../components/ui/button";
1010import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
......@@ -107,35 +107,6 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
107107 );
108108}
109109
110function NoProjectsHero() {
111 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
112
113 return (
114 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
115 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
116 <Empty className="flex-1">
117 <div className="w-full max-w-lg px-8 py-12">
118 <EmptyHeader className="max-w-none">
119 <EmptyTitle className="text-foreground text-2xl sm:text-3xl">
120 What should we work on?
121 </EmptyTitle>
122 <EmptyDescription className="mt-2 text-sm text-muted-foreground/78">
123 Add a project to start your first thread.
124 </EmptyDescription>
125 <div className="mt-6 flex justify-center">
126 <Button size="sm" onClick={openAddProject}>
127 <PlusIcon className="size-4" />
128 Add project
129 </Button>
130 </div>
131 </EmptyHeader>
132 </div>
133 </Empty>
134 </div>
135 </SidebarInset>
136 );
137}
138
139110export const Route = createFileRoute("/_chat/")({
140111 component: ChatIndexRouteView,
141112});
apps/web/src/routes/welcome.tsx+25-18
......@@ -1,14 +1,11 @@
1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
1import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router";
2import { useState } from "react";
23
4import { NoProjectsHero } from "../components/NoProjectsHero";
35import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";
46import { useNewThreadHandler } from "../hooks/useHandleNewThread";
57
6/**
7 * First-run welcome wizard. Full-screen, outside the sidebar shell (the root
8 * route mounts this path bare, like /pair). Reached only via the first-run
9 * gate on the index route; visiting it directly after onboarding is harmless —
10 * finishing again just refreshes the completion flag.
11 */
8/** Onboarding overlays the workspace. Visiting /welcome reopens setup. */
129export const Route = createFileRoute("/welcome")({
1310 beforeLoad: ({ context }) => {
1411 const { authGateState } = context;
......@@ -22,6 +19,10 @@ export const Route = createFileRoute("/welcome")({
2219function WelcomeRouteView() {
2320 const { authGateState } = Route.useRouteContext();
2421 const navigate = useNavigate();
22 // The root shell can remount this pending outlet after the location changes.
23 // Never reopen setup while the destination route is still loading.
24 const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" });
25 const [dismissed, setDismissed] = useState(false);
2526 const openNewThread = useNewThreadHandler();
2627 // An authenticated gate means a primary server is serving this app —
2728 // desktop, `npx t3`, or a dev server — and that server is "this machine"
......@@ -29,17 +30,23 @@ function WelcomeRouteView() {
2930 // local server to offer.
3031 const localAvailable = authGateState.status === "authenticated";
3132 return (
32 <WelcomeWizard
33 localAvailable={localAvailable}
34 onDone={(projectRef) => {
35 if (projectRef !== undefined) {
36 void openNewThread(projectRef, { replace: true }).catch(() => {
33 <>
34 <NoProjectsHero />
35 {isWelcomeRoute && !dismissed ? (
36 <WelcomeWizard
37 localAvailable={localAvailable}
38 onDone={(projectRef) => {
39 setDismissed(true);
40 if (projectRef !== undefined) {
41 void openNewThread(projectRef, { replace: true }).catch(() => {
42 void navigate({ to: "/", replace: true });
43 });
44 return;
45 }
3746 void navigate({ to: "/", replace: true });
38 });
39 return;
40 }
41 void navigate({ to: "/", replace: true });
42 }}
43 />
47 }}
48 />
49 ) : null}
50 </>
4451 );
4552}
docs/user/welcome-wizard.md+18-8
......@@ -3,18 +3,28 @@
33T3 Code shows a setup flow when you open a new installation or connect to the
44hosted app for the first time. Existing workspaces skip this flow.
55
6## Choose a connection
6## Connect your computers
7
8Select one or more computers to set up. If you opened T3 Code directly from a
9server or the desktop app, that computer is already connected and selected.
10It is identified by its name, which may differ from the device running your
11browser.
12
13You can add more computers before continuing:
714
8- **This computer** runs agents on the computer that hosts T3 Code. It does not
9 require an account.
1015- **T3 Connect** connects computers that are signed in to your account. Run
1116 `npx t3 connect` on each computer you want to add, then start T3 Code or run
1217 `npx t3 serve` so the computer stays available.
13- **Pair a server** connects directly to a server on your network or tailnet.
18- **Add a computer** connects directly to a server on your network or tailnet.
1419 Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and
1520 paste the pairing link. You can also run `npx t3 serve --host <address>` and
1621 use `npx t3 pair` when the server is already reachable on your network.
1722
23Saved computers and computers discovered through T3 Connect are selected by
24default. Uncheck any you do not want to set up; this does not disconnect them.
25Continue when your selected computers are connected. Setup checks
26agents across the selected computers, then offers project import grouped by computer.
27
1828If T3 Code cannot confirm the workspace during startup, the setup flow shows
1929**Still connecting** instead of opening the app. Select **Reload** to try again.
2030
......@@ -24,7 +34,7 @@ unreadable settings with defaults.
2434
2535## Check your agents
2636
27T3 Code checks the selected computer for Claude Code and Codex. If an agent is
37T3 Code checks each selected computer for Claude Code and Codex. If an agent is
2838not installed or signed in, select its action to open a terminal with the
2939correct command ready to run. Install uses the vendor's standalone installer,
3040which does not need Node or npm and keeps **Update now** working in Settings.
......@@ -37,7 +47,7 @@ terminal metadata while the terminal process can use them.
3747## Import your projects
3848
3949T3 Code finds directories that Claude Code or Codex has used. The default
40selection includes projects active within the last 30 days. Select **Choose**
50selection includes projects active within the last 30 days. Use the checkboxes
4151to include older projects or change the selection.
4252
4353A large or malformed history can reach the scan limit. T3 Code keeps the
......@@ -60,5 +70,5 @@ with up to 100,000 input records. Run import again to continue a large batch.
6070Completed conversations are not imported again. You can continue without the
6171remaining history.
6272
63You can skip agent setup and project import. Select **Back** to return to a
64previous step.
73You can continue without configuring agents or importing projects, or return to an earlier step
74using the setup progress bar. Navigation pauses while an import is running.