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...@@ -4,7 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
44
5const HEIGHT_TRANSITION_FALLBACK_MS = 250;5const 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}) {
8 const contentRef = useRef<HTMLDivElement>(null);15 const contentRef = useRef<HTMLDivElement>(null);
9 const [heightState, setHeightState] = useState<{16 const [heightState, setHeightState] = useState<{
10 readonly height: number | null;17 readonly height: number | null;
...@@ -22,6 +29,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {...@@ -22,6 +29,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
22 }, [heightState.height, heightState.isClipping]);29 }, [heightState.height, heightState.isClipping]);
2330
24 useLayoutEffect(() => {31 useLayoutEffect(() => {
32 if (holdHeight) return;
25 const element = contentRef.current;33 const element = contentRef.current;
26 if (!element) return;34 if (!element) return;
27 let firstFrameId: number | null = null;35 let firstFrameId: number | null = null;
...@@ -67,7 +75,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {...@@ -67,7 +75,7 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
67 resizeObserver.disconnect();75 resizeObserver.disconnect();
68 cancelPendingFrames();76 cancelPendingFrames();
69 };77 };
70 }, []);78 }, [holdHeight]);
7179
72 return (80 return (
73 <div81 <div
...@@ -85,7 +93,9 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {...@@ -85,7 +93,9 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
85 );93 );
86 }}94 }}
87 >95 >
88 <div ref={contentRef}>{children}</div>96 <div ref={contentRef} style={holdHeight ? { height: "100%" } : undefined}>
97 {children}
98 </div>
89 </div>99 </div>
90 );100 );
91}101}
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";...@@ -3,7 +3,7 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import { EnvironmentId } from "@t3tools/contracts";3import { EnvironmentId } from "@t3tools/contracts";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { AsyncResult } from "effect/unstable/reactivity";5import { AsyncResult } from "effect/unstable/reactivity";
6import { act, type ButtonHTMLAttributes } from "react";6import { act, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
7import { create, type ReactTestRenderer } from "react-test-renderer";7import { create, type ReactTestRenderer } from "react-test-renderer";
8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
99
...@@ -42,6 +42,25 @@ vi.mock("~/state/environments", async () => {...@@ -42,6 +42,25 @@ vi.mock("~/state/environments", async () => {
42 return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) };42 return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) };
43});43});
44vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null }));44vi.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}));
45vi.mock("../ui/button", () => ({64vi.mock("../ui/button", () => ({
46 Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (65 Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
47 <button {...props}>{children}</button>66 <button {...props}>{children}</button>
...@@ -118,6 +137,7 @@ beforeEach(() => {...@@ -118,6 +137,7 @@ beforeEach(() => {
118 error: Option.none(),137 error: Option.none(),
119 };138 };
120 discovery.listEnvironments.mockReset().mockResolvedValue(new Map());139 discovery.listEnvironments.mockReset().mockResolvedValue(new Map());
140 discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined));
121 discovery.refresh.mockReset().mockImplementation(async () => {141 discovery.refresh.mockReset().mockImplementation(async () => {
122 publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() });142 publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() });
123 const environments = await discovery.listEnvironments();143 const environments = await discovery.listEnvironments();
...@@ -133,6 +153,71 @@ afterEach(async () => {...@@ -133,6 +153,71 @@ afterEach(async () => {
133});153});
134154
135describe("cloud onboarding discovery", () => {155describe("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
136 it("shows a newly linked computer without remounting and stops polling once found", async () => {221 it("shows a newly linked computer without remounting and stops polling once found", async () => {
137 discovery.listEnvironments222 discovery.listEnvironments
138 .mockResolvedValueOnce(new Map())223 .mockResolvedValueOnce(new Map())
...@@ -152,6 +237,31 @@ describe("cloud onboarding discovery", () => {...@@ -152,6 +237,31 @@ describe("cloud onboarding discovery", () => {
152 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);237 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
153 });238 });
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
155 it("waits while hidden and refreshes immediately when visible again", async () => {265 it("waits while hidden and refreshes immediately when visible again", async () => {
156 page.visibilityState = "hidden";266 page.visibilityState = "hidden";
157 await mount();267 await mount();
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+94-12
...@@ -20,9 +20,11 @@ import { useRelayEnvironmentDiscovery } from "~/state/environments";...@@ -20,9 +20,11 @@ import { useRelayEnvironmentDiscovery } from "~/state/environments";
20import { useAtomCommand } from "~/state/use-atom-command";20import { useAtomCommand } from "~/state/use-atom-command";
21import { ConnectionStatusDot } from "../ConnectionStatusDot";21import { ConnectionStatusDot } from "../ConnectionStatusDot";
22import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";22import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
23import { Checkbox } from "../ui/checkbox";
23import { Button } from "../ui/button";24import { Button } from "../ui/button";
24import { Skeleton } from "../ui/skeleton";25import { Skeleton } from "../ui/skeleton";
25import { toastManager } from "../ui/toast";26import { toastManager } from "../ui/toast";
27import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
26import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";28import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
2729
28const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;30const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000;
...@@ -59,12 +61,20 @@ export function CloudEnvironmentConnectRows({...@@ -59,12 +61,20 @@ export function CloudEnvironmentConnectRows({
59 showSavedEnvironments = false,61 showSavedEnvironments = false,
60 refreshWhileEmpty = false,62 refreshWhileEmpty = false,
61 empty = null,63 empty = null,
64 selection,
65 onDiscoveryReady,
62}: {66}: {
63 readonly primaryEnvironmentId: EnvironmentId | null;67 readonly primaryEnvironmentId: EnvironmentId | null;
64 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;68 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;
65 readonly showSavedEnvironments?: boolean;69 readonly showSavedEnvironments?: boolean;
66 readonly refreshWhileEmpty?: boolean;70 readonly refreshWhileEmpty?: boolean;
67 readonly empty?: ReactNode;71 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 };
68}) {78}) {
69 const environmentsState = useRelayEnvironmentDiscovery();79 const environmentsState = useRelayEnvironmentDiscovery();
70 const registerEnvironment = useAtomCommand(environmentCatalog.register, {80 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
...@@ -89,33 +99,43 @@ export function CloudEnvironmentConnectRows({...@@ -89,33 +99,43 @@ export function CloudEnvironmentConnectRows({
89 ),99 ),
90 [registerEnvironment],100 [registerEnvironment],
91 );101 );
92 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(102 const [connectingEnvironmentIds, setConnectingEnvironmentIds] = useState<
93 null,103 ReadonlySet<EnvironmentId>
94 );104 >(new Set());
95 const savedById = new Map(105 const savedById = new Map(
96 savedEnvironments.map((environment) => [environment.environmentId, environment]),106 savedEnvironments.map((environment) => [environment.environmentId, environment]),
97 );107 );
98108
99 useEffect(() => {109 useEffect(() => {
100 if (!refreshWhileEmpty || document.visibilityState === "visible") {110 let active = true;
101 void refreshRelayEnvironments();111 if (onDiscoveryReady || !refreshWhileEmpty || document.visibilityState === "visible") {
112 void refreshRelayEnvironments().then(() => {
113 if (active) onDiscoveryReady?.();
114 });
102 }115 }
103 }, [refreshRelayEnvironments, refreshWhileEmpty]);116 return () => {
117 active = false;
118 };
119 }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]);
104120
105 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {121 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
106 setConnectingEnvironmentId(environment.environmentId);122 setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId]));
107 const result = await connectRelayEnvironment(environment);123 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 });
109 if (result._tag === "Success") {129 if (result._tag === "Success") {
110 toastManager.add({130 toastManager.add({
111 type: "success",131 type: "success",
112 title: "Environment added",132 title: "Environment added",
113 description: `Connecting to ${environment.label} through T3 Connect.`,133 description: `Connecting to ${environment.label} through T3 Connect.`,
114 });134 });
115 return;135 return true;
116 }136 }
117 if (isAtomCommandInterrupted(result)) {137 if (isAtomCommandInterrupted(result)) {
118 return;138 return false;
119 }139 }
120 const cause = squashAtomCommandFailure(result);140 const cause = squashAtomCommandFailure(result);
121 const message =141 const message =
...@@ -135,6 +155,7 @@ export function CloudEnvironmentConnectRows({...@@ -135,6 +155,7 @@ export function CloudEnvironmentConnectRows({
135 }155 }
136 : undefined,156 : undefined,
137 });157 });
158 return false;
138 };159 };
139160
140 const visibleEnvironments = [...environmentsState.environments.values()].filter(161 const visibleEnvironments = [...environmentsState.environments.values()].filter(
...@@ -142,6 +163,25 @@ export function CloudEnvironmentConnectRows({...@@ -142,6 +163,25 @@ export function CloudEnvironmentConnectRows({
142 environment.environmentId !== primaryEnvironmentId &&163 environment.environmentId !== primaryEnvironmentId &&
143 (showSavedEnvironments || !savedById.has(environment.environmentId)),164 (showSavedEnvironments || !savedById.has(environment.environmentId)),
144 );165 );
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
145 // Discovery clears its list on refresh, so poll only until a machine appears.185 // Discovery clears its list on refresh, so poll only until a machine appears.
146 const shouldRefreshWhileEmpty =186 const shouldRefreshWhileEmpty =
147 refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline;187 refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline;
...@@ -254,6 +294,48 @@ export function CloudEnvironmentConnectRows({...@@ -254,6 +294,48 @@ export function CloudEnvironmentConnectRows({
254 : availability === "checking"294 : availability === "checking"
255 ? "Available · Checking relay status…"295 ? "Available · Checking relay status…"
256 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");296 : (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 }
257 return (339 return (
258 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>340 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
259 <div className={ITEM_ROW_INNER_CLASSNAME}>341 <div className={ITEM_ROW_INNER_CLASSNAME}>
...@@ -301,10 +383,10 @@ export function CloudEnvironmentConnectRows({...@@ -301,10 +383,10 @@ export function CloudEnvironmentConnectRows({
301 ) : (383 ) : (
302 <Button384 <Button
303 size="sm"385 size="sm"
304 disabled={connectingEnvironmentId !== null}386 disabled={connectingEnvironmentIds.size > 0}
305 onClick={() => void connectEnvironment(environment)}387 onClick={() => void connectEnvironment(environment)}
306 >388 >
307 {connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}389 {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"}
308 </Button>390 </Button>
309 )}391 )}
310 </div>392 </div>
apps/web/src/components/onboarding/FirstRunGate.tsx+1-9
...@@ -2,14 +2,13 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";...@@ -2,14 +2,13 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { useLocation, useNavigate } from "@tanstack/react-router";3import { useLocation, useNavigate } from "@tanstack/react-router";
4import { Atom } from "effect/unstable/reactivity";4import { Atom } from "effect/unstable/reactivity";
5import { useEffect, useLayoutEffect, useState } from "react";5import { useEffect, useState } from "react";
66
7import {7import {
8 ensureClientSettingsHydrated,8 ensureClientSettingsHydrated,
9 useClientSettings,9 useClientSettings,
10 useClientSettingsHydrationStatus,10 useClientSettingsHydrationStatus,
11} from "../../hooks/useSettings";11} from "../../hooks/useSettings";
12import { mountOnboardingTheme } from "../../hooks/useTheme";
13import { useCompleteOnboarding } from "../../onboarding/firstRun";12import { useCompleteOnboarding } from "../../onboarding/firstRun";
14import {13import {
15 isFirstRunWorkspaceProvenanceAuthoritative,14 isFirstRunWorkspaceProvenanceAuthoritative,
...@@ -104,13 +103,6 @@ export function FirstRunGate({...@@ -104,13 +103,6 @@ export function FirstRunGate({
104 }));103 }));
105 const { decision, stalled } = gateState;104 const { decision, stalled } = gateState;
106 const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying";105 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
114 // A workspace still counts as fresh when its only content is the server's106 // A workspace still counts as fresh when its only content is the server's
115 // own cwd auto-bootstrap: web mode creates a project + thread from cwd at107 // own cwd auto-bootstrap: web mode creates a project + thread from cwd at
116 // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no108 // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no
apps/web/src/components/onboarding/WelcomeWizard.tsx+550-683
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import type {3import type {
4 AgentSessionProjectCandidate,
5 EnvironmentId,4 EnvironmentId,
6 ProjectId,5 ProjectId,
7 ScopedProjectRef,6 ScopedProjectRef,
...@@ -18,25 +17,23 @@ import * as Schema from "effect/Schema";...@@ -18,25 +17,23 @@ import * as Schema from "effect/Schema";
18import {17import {
19 ArrowRightIcon,18 ArrowRightIcon,
20 CheckIcon,19 CheckIcon,
21 ChevronLeftIcon,
22 ChevronRightIcon,20 ChevronRightIcon,
23 CloudIcon,21 CloudIcon,
24 CopyIcon,22 CopyIcon,
25 LinkIcon,23 LinkIcon,
26 MonitorIcon,24 MonitorIcon,
27 TerminalIcon,25 TerminalIcon,
28 type LucideIcon,
29} from "lucide-react";26} from "lucide-react";
30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";27import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3128
32import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";29import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
33import { useLocalStorage } from "../../hooks/useLocalStorage";30import { useLocalStorage } from "../../hooks/useLocalStorage";
34import { mountOnboardingTheme } from "../../hooks/useTheme";
35import { hasCloudPublicConfig } from "../../cloud/publicConfig";31import { hasCloudPublicConfig } from "../../cloud/publicConfig";
36import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";32import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
37import { useCompleteOnboarding } from "../../onboarding/firstRun";33import { useCompleteOnboarding } from "../../onboarding/firstRun";
38import {34import {
39 partitionOnboardingProjects,35 partitionOnboardingProjects,
36 onboardingProjectKey,
40 resolveOnboardingLandingProject,37 resolveOnboardingLandingProject,
41 resolveOnboardingProjectId,38 resolveOnboardingProjectId,
42} from "../../onboarding/projectImport.logic";39} from "../../onboarding/projectImport.logic";
...@@ -46,36 +43,37 @@ import {...@@ -46,36 +43,37 @@ import {
46 resolveOnboardingProviderLoginCommand,43 resolveOnboardingProviderLoginCommand,
47 selectOnboardingProvidersByDriver,44 selectOnboardingProvidersByDriver,
48} from "../../onboarding/providerReadiness.logic";45} from "../../onboarding/providerReadiness.logic";
49import {
50 isOnboardingRelayEnvironment,
51 resolveOnboardingTargetEnvironment,
52} from "../../onboarding/targetEnvironment.logic";
53import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";46import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
54import { newProjectId, randomUUID } from "../../lib/utils";47import { newProjectId, randomUUID } from "../../lib/utils";
55import { agentSessionImport, agentSessionScan } from "../../state/agentSessions";48import { agentSessionImport } from "../../state/agentSessions";
56import { readProjects, useProjects } from "../../state/entities";49import { readProjects, useProjects } from "../../state/entities";
57import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";50import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
58import { useEnvironmentQuery } from "../../state/query";51import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic";
52import { useProjectScans } from "../../onboarding/useProjectScans";
59import { projectEnvironment } from "../../state/projects";53import { projectEnvironment } from "../../state/projects";
60import { serverEnvironment } from "../../state/server";54import { serverEnvironment } from "../../state/server";
61import { terminalEnvironment } from "../../state/terminal";55import { terminalEnvironment } from "../../state/terminal";
62import { useAtomCommand } from "../../state/use-atom-command";56import { useAtomCommand } from "../../state/use-atom-command";
63import { connectPairing } from "../../connection/onboarding";57import { connectPairing } from "../../connection/onboarding";
64import { isElectron } from "../../env";
65import { formatRelativeTimeLabel } from "../../timestampFormat";
66import { getProviderSummary } from "../settings/providerStatus";58import { getProviderSummary } from "../settings/providerStatus";
67import { getDriverOption } from "../settings/providerDriverMeta";59import { getDriverOption } from "../settings/providerDriverMeta";
68import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
69import { TerminalViewport } from "../ThreadTerminalDrawer";60import { TerminalViewport } from "../ThreadTerminalDrawer";
61import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
62import { T3Wordmark } from "../T3Wordmark";
70import { Button } from "../ui/button";63import { Button } from "../ui/button";
71import { Checkbox } from "../ui/checkbox";64import { Checkbox } from "../ui/checkbox";
72import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";65import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
73import { Input } from "../ui/input";66import { 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";
74import { toastManager } from "../ui/toast";72import { toastManager } from "../ui/toast";
75import { cn } from "../../lib/utils";73import { cn } from "../../lib/utils";
7674
77/**75/**
78 * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a76 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
79 * fresh install (no completed-onboarding flag, empty workspace). Flow per the77 * fresh install (no completed-onboarding flag, empty workspace). Flow per the
80 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →78 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →
81 * agent setup with inline install terminal → project import → main screen.79 * agent setup with inline install terminal → project import → main screen.
...@@ -83,32 +81,8 @@ import { cn } from "../../lib/utils";...@@ -83,32 +81,8 @@ import { cn } from "../../lib/utils";
83 * re-runnable by clearing the flag.81 * re-runnable by clearing the flag.
84 */82 */
8583
86type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import";84type WizardStep = "connection" | "agents" | "import";
8785const NO_ENVIRONMENTS: readonly EnvironmentId[] = [];
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}
11286
113const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup");87const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup");
114const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const;88const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const;
...@@ -118,23 +92,48 @@ export function WelcomeWizard({...@@ -118,23 +92,48 @@ export function WelcomeWizard({
118 localAvailable,92 localAvailable,
119 onDone,93 onDone,
120}: {94}: {
121 /**95 /** Whether this client is authenticated to the server serving the app. */
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 */
127 readonly localAvailable: boolean;96 readonly localAvailable: boolean;
128 readonly onDone: (projectRef?: ScopedProjectRef) => void;97 readonly onDone: (projectRef?: ScopedProjectRef) => void;
129}) {98}) {
130 useLayoutEffect(() => mountOnboardingTheme(), []);
131 const completeOnboarding = useCompleteOnboarding();99 const completeOnboarding = useCompleteOnboarding();
132 const [step, setStep] = useState<WizardStep>("connection");100 const [step, setStep] = useState<WizardStep>("connection");
133 const [mode, setMode] = useState<ConnectionMode>("local");101 const { environments } = useEnvironments();
134 const [pairedEnvironmentId, setPairedEnvironmentId] = useState<EnvironmentId | null>(null);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);
135 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);106 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);
136 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);107 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 };
138 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;137 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
139 const finish = useCallback(138 const finish = useCallback(
140 (projectRef?: ScopedProjectRef) => {139 (projectRef?: ScopedProjectRef) => {
...@@ -178,178 +177,216 @@ export function WelcomeWizard({...@@ -178,178 +177,216 @@ export function WelcomeWizard({
178 );177 );
179178
180 return (179 return (
181 <div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">180 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
182 {isElectron ? (181 <DialogPopup
183 <div182 className="max-w-xl overflow-x-hidden overflow-y-auto"
184 aria-hidden183 bottomStickOnMobile={false}
185 className="drag-region h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 wco:pr-[var(--workspace-native-controls-inset)]"184 showCloseButton={false}
186 />185 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
187 ) : null}186 >
188 <main className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">187 <DialogTitle className="sr-only">Set up T3 Code</DialogTitle>
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">188 <div className="flex min-h-0 flex-col">
190 <aside className="flex min-w-0 flex-col justify-between sm:min-h-72">189 <DialogHeader className="gap-4">
191 <nav190 <div className="flex items-baseline gap-1.5" role="img" aria-label="T3 Code">
192 aria-label="Setup progress"191 <T3Wordmark className="h-4 w-auto shrink-0" aria-hidden />
193 className="flex flex-wrap gap-x-5 gap-y-1 sm:flex-col sm:gap-1"192 <span className="text-[1.4rem] font-medium tracking-tight text-muted-foreground">
194 >193 Code
195 {ONBOARDING_STAGES.map((stage, index) => (194 </span>
196 <div195 </div>
197 key={stage}196 <WizardSteps
198 aria-current={index === stageIndex ? "step" : undefined}197 steps={ONBOARDING_STAGES}
199 className={cn(198 currentStep={stageIndex}
200 "flex min-h-9 items-center gap-2.5 text-sm",199 isStepDisabled={(index) => isImporting || index >= stageIndex}
201 index < stageIndex200 onStepChange={(index) => {
202 ? "text-success-foreground"201 if (isImporting || index > stageIndex) return;
203 : index === stageIndex202 setStep(index === 0 ? "connection" : "agents");
204 ? "text-foreground"203 }}
205 : "text-muted-foreground/60",204 />
206 )}205 </DialogHeader>
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>
224206
225 <section className="min-w-0">207 <WizardPanel className="min-w-0" holdHeight={isLoadingProjects}>
226 {step === "connection" ? (208 {step === "connection" ? (
227 <ConnectionStep209 <ConnectionStep
228 localAvailable={localAvailable}210 expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}
229 localLabel={targetEnvironment?.label ?? "This computer"}211 selectedIds={selectedIds}
230 onLocal={() => {212 autoSelectedComputers={autoSelectedComputers.current}
231 setMode("local");213 onSelectionChange={setSelection}
232 setPairedEnvironmentId(null);214 onToggleEnvironment={(environmentId, checked) =>
233 setStep("agents");215 setSelection((current) => {
234 }}216 const next = new Set(current ?? selectedIds);
235 onConnect={() => {217 if (checked) next.add(environmentId);
236 setMode("connect");218 else next.delete(environmentId);
237 setPairedEnvironmentId(null);219 return next;
238 setStep("connect-machines");220 })
239 }}221 }
240 onDirect={() => {222 onContinue={() =>
241 setMode("direct");223 startSetup(
242 setPairedEnvironmentId(null);224 environments
243 setStep("pair-direct");225 .filter((environment) => selectedIds.has(environment.environmentId))
244 }}226 .map((environment) => environment.environmentId),
245 />227 )
246 ) : step === "connect-machines" ? (228 }
247 <ConnectMachinesStep
248 onBack={() => setStep("connection")}
249 onContinue={() => setStep("agents")}
250 />
251 ) : step === "pair-direct" ? (
252 <PairDirectStep
253 onBack={() => setStep("connection")}
254 onPaired={(environmentId) => {229 onPaired={(environmentId) => {
255 setPairedEnvironmentId(environmentId);230 setSelection(new Set([...selectedIds, environmentId]));
256 setStep("agents");
257 }}231 }}
258 />232 />
259 ) : step === "agents" ? (233 ) : step === "agents" ? (
260 <AgentsStep234 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
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 />
275 ) : (235 ) : (
276 <ImportStep236 <ImportStep
277 mode={mode}237 scans={scans}
278 pairedEnvironmentId={pairedEnvironmentId}238 isImporting={isImporting}
279 onBack={() => setStep("agents")}239 setIsImporting={setIsImporting}
280 onDone={finish}240 onDone={finish}
281 />241 />
282 )}242 )}
283 </section>243 </WizardPanel>
284 </div>244 </div>
285 </main>245 </DialogPopup>
286 </div>246 </Dialog>
287 );247 );
288}248}
289249
290// ── Step 1: connection choice ────────────────────────────────250// ── Step 1: connection choice ────────────────────────────────
291251
292function ConnectionStep({252function ConnectionStep({
293 localAvailable,253 autoSelectedComputers,
294 localLabel,254 expandPairingInitially,
295 onLocal,255 selectedIds,
296 onConnect,256 onSelectionChange,
297 onDirect,257 onToggleEnvironment,
258 onContinue,
259 onPaired,
298}: {260}: {
299 readonly localAvailable: boolean;261 readonly autoSelectedComputers: Set<EnvironmentId>;
300 readonly localLabel: string;262 readonly expandPairingInitially: boolean;
301 readonly onLocal: () => void;263 readonly selectedIds: ReadonlySet<EnvironmentId>;
302 readonly onConnect: () => void;264 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId>) => void;
303 readonly onDirect: () => void;265 readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
266 readonly onContinue: () => void;
267 readonly onPaired: (environmentId: EnvironmentId) => void;
304}) {268}) {
269 const { environments } = useEnvironments();
305 const cloudEnabled = hasCloudPublicConfig();270 const cloudEnabled = hasCloudPublicConfig();
306 const [choice, setChoice] = useState<"local" | "connect" | "direct">(271 const directEnvironments = environments.filter(
307 localAvailable ? "local" : cloudEnabled ? "connect" : "direct",272 (environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment),
308 );273 );
309274 const [pairingOpen, setPairingOpen] = useState(expandPairingInitially);
310 const advance = () => {275 const [isPairing, setIsPairing] = useState(false);
311 if (choice === "local") onLocal();276 const ready =
312 else if (choice === "connect") onConnect();277 selectedIds.size > 0 &&
313 else onDirect();278 [...selectedIds].every((id) =>
314 };279 environments.some(
315280 (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]);
316 return (294 return (
317 <>295 <>
318 <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">Where is your code?</h1>296 <h1 className="text-2xl font-semibold tracking-tight text-foreground">
319 <p className="mt-2.5 text-sm text-muted-foreground">Choose where your agents will run.</p>297 Connect your computers
320 <div className="mt-8 border-t border-border">298 </h1>
321 {localAvailable ? (299 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
322 <ConnectionOption300 Choose one or more computers. We’ll set up agents and projects on each.
323 icon={MonitorIcon}301 </p>
324 title="This computer"302 {directEnvironments.length > 0 ? (
325 description={localLabel}303 <fieldset className="mt-5 space-y-2">
326 truncateDescription304 <legend className="sr-only">Computers to set up</legend>
327 detail="No account"305 {directEnvironments.map((environment) => (
328 selected={choice === "local"}306 <label
329 onSelect={() => setChoice("local")}307 key={environment.environmentId}
330 />308 className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-3"
331 ) : null}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">
332 {cloudEnabled ? (340 {cloudEnabled ? (
333 <ConnectionOption341 <ConnectAccountOption
334 icon={CloudIcon}342 autoSelectedComputers={autoSelectedComputers}
335 title="T3 Connect"343 disabled={isPairing}
336 description="Your computers, wherever you are"344 selectedIds={selectedIds}
337 detail="Sign in"345 onToggleEnvironment={onToggleEnvironment}
338 selected={choice === "connect"}
339 onSelect={() => setChoice("connect")}
340 />346 />
341 ) : null}347 ) : null}
342 <ConnectionOption348 <Collapsible
343 icon={LinkIcon}349 open={pairingOpen}
344 title="Pair a server"350 onOpenChange={setPairingOpen}
345 description="Local network or Tailscale"351 className="rounded-lg border border-border bg-background"
346 detail="Pairing link"352 >
347 selected={choice === "direct"}353 <CollapsibleTrigger
348 onSelect={() => setChoice("direct")}354 disabled={isPairing}
349 />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>
350 </div>382 </div>
351 <div className="mt-7 flex justify-end">383 <div className="mt-6 flex items-center justify-end gap-3">
352 <Button className="gap-2" onClick={advance}>384 <Button
385 ref={continueRef}
386 autoFocus={!expandPairingInitially}
387 disabled={!ready || isPairing}
388 onClick={onContinue}
389 >
353 Continue390 Continue
354 <ArrowRightIcon className="size-3.5" />391 <ArrowRightIcon className="size-3.5" />
355 </Button>392 </Button>
...@@ -358,203 +395,108 @@ function ConnectionStep({...@@ -358,203 +395,108 @@ function ConnectionStep({
358 );395 );
359}396}
360397
361function ConnectionOption({398function ConnectAccountOption({
362 icon: Icon,399 autoSelectedComputers,
363 title,400 disabled,
364 description,401 selectedIds,
365 truncateDescription = false,402 onToggleEnvironment,
366 detail,
367 selected,
368 onSelect,
369}: {403}: {
370 readonly icon: LucideIcon;404 readonly autoSelectedComputers: Set<EnvironmentId>;
371 readonly title: string;405 readonly disabled: boolean;
372 readonly description: string;406 readonly selectedIds: ReadonlySet<EnvironmentId>;
373 readonly truncateDescription?: boolean;407 readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
374 readonly detail: string;
375 readonly selected: boolean;
376 readonly onSelect: () => void;
377}) {408}) {
378 return (409 const { environments } = useEnvironments();
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.
436 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });410 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
437 const { openAuthPrompt } = useT3ConnectAuthPrompt();411 const { openAuthPrompt } = useT3ConnectAuthPrompt();
438 const { environments } = useEnvironments();412 const [expanded, setExpanded] = useState(true);
439 const primaryEnvironment = usePrimaryEnvironment();413 const [discoveryReady, setDiscoveryReady] = useState(false);
440 const savedEnvironments = environments.filter(isOnboardingRelayEnvironment);414 const onDiscoveryReady = useCallback(() => setDiscoveryReady(true), []);
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 }
465415
466 return (416 return (
467 <StepShell417 <Collapsible
468 title={hasRemoteMachines ? "Your computers" : "Connect your computer"}418 open={expanded && !!isSignedIn && discoveryReady}
469 description={419 onOpenChange={setExpanded}
470 hasRemoteMachines420 className="rounded-lg border border-border bg-background"
471 ? "Connected to your T3 account."
472 : "Run this command on the computer with your code."
473 }
474 onBack={onBack}
475 >421 >
476 {hasRemoteMachines ? (422 <CollapsibleTrigger
477 <>423 disabled={disabled || !isLoaded}
478 <div className="mt-6 overflow-hidden border-y border-border">424 onClick={(event) => {
479 <CloudEnvironmentConnectRows425 if (!isSignedIn) {
480 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}426 event.preventDefault();
481 savedEnvironments={savedEnvironments}427 setExpanded(true);
482 showSavedEnvironments428 openAuthPrompt();
483 empty={null}429 }
484 />430 }}
485 </div>431 render={
486 <Collapsible className="mt-4">432 <Button
487 <CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">433 variant="ghost"
488 <ChevronRightIcon className="size-3.5 transition-transform duration-200 group-data-panel-open:rotate-90" />434 className="h-auto min-h-14 w-full justify-start gap-3 px-3 py-3 text-left whitespace-normal sm:h-auto"
489 Add another machine435 />
490 </CollapsibleTrigger>436 }
491 <CollapsiblePanel>437 >
492 <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-2" />438 <CloudIcon className="size-4 text-muted-foreground" />
493 <p className="mt-2 text-xs text-muted-foreground">439 <span className="flex-1">T3 Connect</span>
494 Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "}440 <span className="text-xs text-muted-foreground">
495 <code className="font-mono">npx t3 serve</code>.441 {!isLoaded
496 </p>442 ? "Loading sign-in…"
497 </CollapsiblePanel>443 : !isSignedIn
498 </Collapsible>444 ? "Sign in"
499 <div className="mt-7 flex justify-end">445 : !discoveryReady
500 <Button onClick={onContinue}>Continue</Button>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}
501 </div>469 </div>
502 </>470 <p className="text-sm text-muted-foreground">
503 ) : (471 Run this on each computer you want to connect.
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>.
509 </p>472 </p>
510 <div className="mt-5 overflow-hidden border-y border-border">473 <CommandBlock command="npx t3 connect" className="mt-3" />
511 <CloudEnvironmentConnectRows474 <p className="mt-3 text-xs text-muted-foreground">
512 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}475 Keep T3 Code running. Select the computers you want to set up above.
513 savedEnvironments={savedEnvironments}476 </p>
514 showSavedEnvironments477 </div>
515 refreshWhileEmpty478 </CollapsiblePanel>
516 empty={479 </Collapsible>
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>
537 );480 );
538}481}
539482
540// ── Step 2′: Direct pairing ──────────────────────────────────483// ── Step 2′: Direct pairing ──────────────────────────────────
541484
542/**485/**
543 * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the486 * Register a computer in this browser using a server-minted pairing link.
544 * server, paste the URL here. Registers the remote environment in this
545 * browser's catalog (same path the hosted /pair surface uses).
546 */487 */
547function PairDirectStep({488function PairingForm({
548 onBack,489 isPairing,
490 setIsPairing,
549 onPaired,491 onPaired,
550}: {492}: {
551 readonly onBack: () => void;493 readonly isPairing: boolean;
494 readonly setIsPairing: (value: boolean) => void;
552 readonly onPaired: (environmentId: EnvironmentId) => void;495 readonly onPaired: (environmentId: EnvironmentId) => void;
553}) {496}) {
554 const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false });497 const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false });
555 const [pairingUrl, setPairingUrl] = useState("");498 const [pairingUrl, setPairingUrl] = useState("");
556 const [errorMessage, setErrorMessage] = useState("");499 const [errorMessage, setErrorMessage] = useState("");
557 const [isPairing, setIsPairing] = useState(false);
558 const mountedRef = useRef(true);500 const mountedRef = useRef(true);
559501
560 useEffect(() => {502 useEffect(() => {
...@@ -565,9 +507,10 @@ function PairDirectStep({...@@ -565,9 +507,10 @@ function PairDirectStep({
565 }, []);507 }, []);
566508
567 const submit = async () => {509 const submit = async () => {
510 if (isPairing || pairingUrl.trim().length === 0) return;
568 setIsPairing(true);511 setIsPairing(true);
569 setErrorMessage("");512 setErrorMessage("");
570 const result = await connectPairingEnvironment({ pairingUrl });513 const result = await connectPairingEnvironment({ pairingUrl: pairingUrl.trim() });
571 if (!mountedRef.current) return;514 if (!mountedRef.current) return;
572 setIsPairing(false);515 setIsPairing(false);
573 if (result._tag === "Success") {516 if (result._tag === "Success") {
...@@ -580,28 +523,23 @@ function PairDirectStep({...@@ -580,28 +523,23 @@ function PairDirectStep({
580 };523 };
581524
582 return (525 return (
583 <StepShell526 <>
584 title="Pair a server"527 <form
585 description="Connect with a one-time pairing link."528 className="space-y-3"
586 onBack={onBack}529 onSubmit={(event) => {
587 >530 event.preventDefault();
588 <div className="mt-7 space-y-5">531 void submit();
589 <div>532 }}
590 <p className="text-sm text-muted-foreground">533 >
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>
599 <div>534 <div>
600 <label className="block text-sm text-muted-foreground" htmlFor="onboarding-pairing-url">535 <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 link536 Pairing link
602 </label>537 </label>
603 <Input538 <Input
604 id="onboarding-pairing-url"539 id="onboarding-pairing-url"
540 autoFocus
541 aria-invalid={errorMessage.length > 0}
542 aria-describedby={errorMessage ? "onboarding-pairing-error" : undefined}
605 className="mt-2"543 className="mt-2"
606 size="lg"544 size="lg"
607 autoCapitalize="none"545 autoCapitalize="none"
...@@ -609,31 +547,55 @@ function PairDirectStep({...@@ -609,31 +547,55 @@ function PairDirectStep({
609 autoCorrect="off"547 autoCorrect="off"
610 spellCheck={false}548 spellCheck={false}
611 nativeInput549 nativeInput
612 disabled={isPairing}550 readOnly={isPairing}
613 placeholder="https://your-server:5230/pair#token=…"551 placeholder="https://your-server:5230/pair#token=…"
614 value={pairingUrl}552 value={pairingUrl}
615 onChange={(event) => setPairingUrl(event.currentTarget.value)}553 onChange={(event) => setPairingUrl(event.currentTarget.value)}
616 onKeyDown={(event) => {554 onKeyDown={(event) => {
617 if (event.nativeEvent.isComposing || event.keyCode === 229) return;555 if (
618 if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit();556 event.key === "Enter" &&
557 (event.nativeEvent.isComposing || event.keyCode === 229)
558 ) {
559 event.preventDefault();
560 }
619 }}561 }}
620 />562 />
621 </div>563 </div>
622 {errorMessage ? (564 {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 >
624 {errorMessage}570 {errorMessage}
625 </div>571 </div>
626 ) : null}572 ) : null}
627 </div>573 <Collapsible>
628 <div className="mt-7 flex justify-end">574 <div className="flex items-center justify-between gap-3">
629 <Button575 <CollapsibleTrigger
630 disabled={isPairing || pairingUrl.trim().length === 0}576 type="button"
631 onClick={() => void submit()}577 className="group flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
632 >578 >
633 {isPairing ? "Pairing..." : "Connect"}579 <ChevronRightIcon className="size-3.5 group-data-panel-open:rotate-90" />
634 </Button>580 Need a pairing link?
635 </div>581 </CollapsibleTrigger>
636 </StepShell>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 </>
637 );599 );
638}600}
639601
...@@ -660,58 +622,48 @@ interface AgentTerminalSession {...@@ -660,58 +622,48 @@ interface AgentTerminalSession {
660 * login that follows.622 * login that follows.
661 */623 */
662function AgentsStep({624function AgentsStep({
663 mode,625 environmentIds,
664 pairedEnvironmentId,
665 onBack,
666 onContinue,626 onContinue,
667 onSkip,
668}: {627}: {
669 readonly mode: ConnectionMode;628 readonly environmentIds: readonly EnvironmentId[];
670 readonly pairedEnvironmentId: EnvironmentId | null;
671 readonly onBack: () => void;
672 readonly onContinue: () => void;629 readonly onContinue: () => void;
673 readonly onSkip: () => void;
674}) {630}) {
675 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);631 const { environments } = useEnvironments();
676 if (targetEnvironment === null) {632 return (
677 return (633 <StepShell title="Your agents" description="Agents available on your selected computers.">
678 <StepShell634 <ScrollArea
679 title="Your agents"635 scrollFade
680 description="Waiting for this computer to connect."636 className="mt-5 h-auto max-h-96 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
681 onBack={onBack}
682 >637 >
683 <div className="mt-6 flex justify-end">638 <div className="space-y-5 pr-3">
684 <Button variant="ghost-muted" onClick={onSkip}>639 {environmentIds.map((environmentId) => (
685 Skip for now640 <ConnectedAgentsStep
686 </Button>641 key={environmentId}
642 environmentId={environmentId}
643 machineLabel={
644 environments.find((environment) => environment.environmentId === environmentId)
645 ?.label ?? "Computer"
646 }
647 />
648 ))}
687 </div>649 </div>
688 </StepShell>650 </ScrollArea>
689 );651 <div className="mt-6 flex justify-end">
690 }652 <Button autoFocus onClick={onContinue}>
691 return (653 Continue
692 <ConnectedAgentsStep654 <ArrowRightIcon className="size-3.5" />
693 key={targetEnvironment.environmentId}655 </Button>
694 environmentId={targetEnvironment.environmentId}656 </div>
695 machineLabel={targetEnvironment.label}657 </StepShell>
696 onBack={onBack}
697 onContinue={onContinue}
698 onSkip={onSkip}
699 />
700 );658 );
701}659}
702660
703function ConnectedAgentsStep({661function ConnectedAgentsStep({
704 environmentId,662 environmentId,
705 machineLabel,663 machineLabel,
706 onBack,
707 onContinue,
708 onSkip,
709}: {664}: {
710 readonly environmentId: EnvironmentId;665 readonly environmentId: EnvironmentId;
711 readonly machineLabel: string;666 readonly machineLabel: string;
712 readonly onBack: () => void;
713 readonly onContinue: () => void;
714 readonly onSkip: () => void;
715}) {667}) {
716 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));668 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
717 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {669 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
...@@ -732,12 +684,10 @@ function ConnectedAgentsStep({...@@ -732,12 +684,10 @@ function ConnectedAgentsStep({
732 driver,684 driver,
733 provider: byDriver.get(driver),685 provider: byDriver.get(driver),
734 }));686 }));
735 const readyCount = primaryAgents.filter(
736 ({ provider }) => getOnboardingProviderState(provider) === "ready",
737 ).length;
738 return (687 return (
739 <StepShell title="Your agents" description={`Detected on ${machineLabel}.`} onBack={onBack}>688 <section>
740 <div className="mt-7 border-t border-border">689 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>
690 <div className="space-y-1.5">
741 {primaryAgents.map(({ driver, provider }) => (691 {primaryAgents.map(({ driver, provider }) => (
742 <AgentCard692 <AgentCard
743 key={driver}693 key={driver}
...@@ -778,21 +728,7 @@ function ConnectedAgentsStep({...@@ -778,21 +728,7 @@ function ConnectedAgentsStep({
778 }}728 }}
779 />729 />
780 ) : null}730 ) : null}
781 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">731 </section>
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>
796 );732 );
797}733}
798734
...@@ -816,13 +752,13 @@ function AgentCard({...@@ -816,13 +752,13 @@ function AgentCard({
816 const providerState = getOnboardingProviderState(provider);752 const providerState = getOnboardingProviderState(provider);
817753
818 return (754 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">
820 {Icon ? (756 {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")} />
822 ) : null}758 ) : null}
823 <div className="min-w-0 flex-1">759 <div className="min-w-0 flex-1">
824 <span className="block text-sm font-medium text-foreground">{displayName}</span>760 <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">
826 {summary.headline}762 {summary.headline}
827 {summary.detail ? ` · ${summary.detail}` : ""}763 {summary.detail ? ` · ${summary.detail}` : ""}
828 </p>764 </p>
...@@ -1008,35 +944,22 @@ function AgentInstallTerminal({...@@ -1008,35 +944,22 @@ function AgentInstallTerminal({
1008944
1009// ── Step 4: import ───────────────────────────────────────────945// ── 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 */
1017function ImportStep({947function ImportStep({
1018 mode,948 scans,
1019 pairedEnvironmentId,949 isImporting,
1020 onBack,950 setIsImporting,
1021 onDone,951 onDone,
1022}: {952}: {
1023 readonly mode: ConnectionMode;953 readonly scans: ReturnType<typeof useProjectScans>;
1024 readonly pairedEnvironmentId: EnvironmentId | null;954 readonly isImporting: boolean;
1025 readonly onBack: () => void;955 readonly setIsImporting: (value: boolean) => void;
1026 readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>;956 readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>;
1027}) {957}) {
1028 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);958 const { environments } = useEnvironments();
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 );
1034 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });959 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
1035 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });960 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
1036 const projects = useProjects();961 const projects = useProjects();
1037 const [choosing, setChoosing] = useState(false);962 const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
1038 const [deselected, setDeselected] = useState<ReadonlySet<string>>(new Set());
1039 const [isImporting, setIsImporting] = useState(false);
1040 const [importError, setImportError] = useState("");963 const [importError, setImportError] = useState("");
1041 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);964 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
1042 // Keep project creation attempts separate from completed history imports so both can retry.965 // Keep project creation attempts separate from completed history imports so both can retry.
...@@ -1048,27 +971,17 @@ function ImportStep({...@@ -1048,27 +971,17 @@ function ImportStep({
1048 );971 );
1049 const importGenerationRef = useRef(0);972 const importGenerationRef = useRef(0);
1050973
1051 // Candidate paths are per-environment; a target switch would otherwise974 // Ignore command completions after leaving the import step.
1052 // leave stale entries in the deselection set (and stale success records).
1053 useEffect(() => {975 useEffect(() => {
1054 importGenerationRef.current += 1;976 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();
1063 return () => {977 return () => {
1064 importGenerationRef.current += 1;978 importGenerationRef.current += 1;
1065 };979 };
1066 }, [environmentId]);980 }, []);
1067981
1068 useEffect(() => {982 useEffect(() => {
1069 if (983 if (
1070 landingProject !== null &&984 landingProject !== null &&
1071 landingProject.environmentId === environmentId &&
1072 projects.some(985 projects.some(
1073 (project) =>986 (project) =>
1074 project.id === landingProject.projectId &&987 project.id === landingProject.projectId &&
...@@ -1080,19 +993,26 @@ function ImportStep({...@@ -1080,19 +993,26 @@ function ImportStep({
1080 if (!completed) setIsImporting(false);993 if (!completed) setIsImporting(false);
1081 });994 });
1082 }995 }
1083 }, [environmentId, landingProject, onDone, projects]);996 }, [landingProject, onDone, projects, setIsImporting]);
1084997
1085 const { available: candidates, recent } = useMemo(998 const { available: candidates, recent } = useMemo(
1086 () => partitionOnboardingProjects(scan.data?.candidates ?? []),999 () =>
1087 [scan.data],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),
1088 );1015 );
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
1097 const finishAfterImport = () => {1017 const finishAfterImport = () => {
1098 const projectRef = resolveOnboardingLandingProject(1018 const projectRef = resolveOnboardingLandingProject(
...@@ -1108,14 +1028,15 @@ function ImportStep({...@@ -1108,14 +1028,15 @@ function ImportStep({
1108 setLandingProject(projectRef);1028 setLandingProject(projectRef);
1109 };1029 };
11101030
1111 const runImport = async (selection: ReadonlyArray<AgentSessionProjectCandidate>) => {1031 const runImport = async (selection: typeof candidates) => {
1112 if (environmentId === null || selection.length === 0) {1032 if (isImporting) return;
1033 if (selection.length === 0) {
1113 void onDone();1034 void onDone();
1114 return;1035 return;
1115 }1036 }
1116 setIsImporting(true);1037 setIsImporting(true);
1117 setImportError("");1038 setImportError("");
1118 lastImportSelectionRef.current = selection.map((candidate) => candidate.path);1039 lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
1119 const importGeneration = importGenerationRef.current;1040 const importGeneration = importGenerationRef.current;
1120 const importedProjects = importedProjectsRef.current;1041 const importedProjects = importedProjectsRef.current;
1121 const projectAttempts = projectAttemptsRef.current;1042 const projectAttempts = projectAttemptsRef.current;
...@@ -1126,29 +1047,30 @@ function ImportStep({...@@ -1126,29 +1047,30 @@ function ImportStep({
1126 // duplicate-root invariant and read as a failure).1047 // duplicate-root invariant and read as a failure).
1127 let importedProjectsCount =1048 let importedProjectsCount =
1128 importedProjects.size > 01049 importedProjects.size > 0
1129 ? selection.filter((candidate) => importedProjects.has(candidate.path)).length1050 ? selection.filter((candidate) => importedProjects.has(candidate.key)).length
1130 : 0;1051 : 0;
1131 let importedThreadCount = 0;1052 let importedThreadCount = 0;
1132 let skippedThreadCount = 0;1053 let skippedThreadCount = 0;
1133 let shouldRefreshScan = false;1054 const refreshEnvironments = new Set<EnvironmentId>();
1134 for (const candidate of selection) {1055 for (const candidate of selection) {
1056 const { environmentId } = candidate;
1135 if (1057 if (
1136 importGeneration !== importGenerationRef.current ||1058 importGeneration !== importGenerationRef.current ||
1137 importedProjects !== importedProjectsRef.current1059 importedProjects !== importedProjectsRef.current
1138 ) {1060 ) {
1139 return;1061 return;
1140 }1062 }
1141 if (importedProjects.has(candidate.path)) continue;1063 if (importedProjects.has(candidate.key)) continue;
1142 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);1064 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
1143 if (projectId === null) {1065 if (projectId === null) {
1144 let attempt = projectAttempts.get(candidate.path);1066 let attempt = projectAttempts.get(candidate.key);
1145 if (attempt === undefined) {1067 if (attempt === undefined) {
1146 const nextProjectId = newProjectId();1068 const nextProjectId = newProjectId();
1147 attempt = {1069 attempt = {
1148 projectId: nextProjectId,1070 projectId: nextProjectId,
1149 commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),1071 commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`),
1150 };1072 };
1151 projectAttempts.set(candidate.path, attempt);1073 projectAttempts.set(candidate.key, attempt);
1152 }1074 }
1153 projectId = attempt.projectId;1075 projectId = attempt.projectId;
1154 const result = await createProject({1076 const result = await createProject({
...@@ -1170,8 +1092,8 @@ function ImportStep({...@@ -1170,8 +1092,8 @@ function ImportStep({
1170 }1092 }
1171 if (result._tag !== "Success") {1093 if (result._tag !== "Success") {
1172 if (!isAtomCommandInterrupted(result)) {1094 if (!isAtomCommandInterrupted(result)) {
1173 projectAttempts.delete(candidate.path);1095 projectAttempts.delete(candidate.key);
1174 shouldRefreshScan = true;1096 refreshEnvironments.add(environmentId);
1175 }1097 }
1176 continue;1098 continue;
1177 }1099 }
...@@ -1192,20 +1114,22 @@ function ImportStep({...@@ -1192,20 +1114,22 @@ function ImportStep({
1192 skippedThreadCount += threadImportResult.value.skippedCount;1114 skippedThreadCount += threadImportResult.value.skippedCount;
1193 if (threadImportResult.value.importedCount > 0) {1115 if (threadImportResult.value.importedCount > 0) {
1194 projectsWithImportedHistoryRef.current.set(1116 projectsWithImportedHistoryRef.current.set(
1195 candidate.path,1117 candidate.key,
1196 scopeProjectRef(environmentId, projectId),1118 scopeProjectRef(environmentId, projectId),
1197 );1119 );
1198 }1120 }
1199 if (threadImportResult.value.skippedCount === 0) {1121 if (threadImportResult.value.skippedCount === 0) {
1200 importedProjectsCount += 1;1122 importedProjectsCount += 1;
1201 importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId));1123 importedProjects.set(candidate.key, scopeProjectRef(environmentId, projectId));
1202 }1124 }
1203 } else if (!isAtomCommandInterrupted(threadImportResult)) {1125 } else if (!isAtomCommandInterrupted(threadImportResult)) {
1204 projectAttempts.delete(candidate.path);1126 projectAttempts.delete(candidate.key);
1205 shouldRefreshScan = true;1127 refreshEnvironments.add(environmentId);
1206 }1128 }
1207 }1129 }
1208 if (shouldRefreshScan) scan.refresh();1130 for (const scan of scans) {
1131 if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
1132 }
1209 setIsImporting(false);1133 setIsImporting(false);
1210 if (importedProjectsCount < selection.length) {1134 if (importedProjectsCount < selection.length) {
1211 if (importedThreadCount > 0 && skippedThreadCount > 0) {1135 if (importedThreadCount > 0 && skippedThreadCount > 0) {
...@@ -1228,166 +1152,129 @@ function ImportStep({...@@ -1228,166 +1152,129 @@ function ImportStep({
1228 finishAfterImport();1152 finishAfterImport();
1229 };1153 };
12301154
1231 if (environmentId === null || (scan.isPending && scan.data === null)) {1155 if (scans.every((scan) => scan.data === null) && scans.some((scan) => scan.isPending)) {
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));
1282 return (1156 return (
1283 <StepShell1157 <div className="flex h-full min-h-40 flex-col">
1284 title="Choose your projects"1158 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Your projects</h1>
1285 onBack={() => setChoosing(false)}1159 <div className="flex flex-1 flex-col items-center justify-center gap-3 py-6">
1286 backDisabled={isImporting}1160 <Spinner className="size-5 text-muted-foreground" />
1287 description={`${candidates.length} found on ${machineLabel}.`}1161 <p className="text-center text-sm text-muted-foreground">
1288 >1162 Looking for projects from Claude Code and Codex…
1289 {scanLimitNotice}1163 </p>
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 ))}
1319 </div>1164 </div>
1320 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}1165 <div className="flex justify-end">
1321 <div className="mt-7 flex flex-wrap items-center justify-between gap-3">1166 <Button variant="ghost-muted" onClick={() => void onDone()}>
1322 <Button1167 Do not import projects
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}`}
1334 </Button>1168 </Button>
1335 </div>1169 </div>
1336 </StepShell>1170 </div>
1337 );1171 );
1338 }1172 }
13391173
1340 return (1174 return (
1341 <StepShell1175 <StepShell
1342 title="Your recent projects"1176 title="Choose your projects"
1343 description={`${recent.length} ${recent.length === 1 ? "project" : "projects"} found on ${machineLabel}.${more > 0 ? ` ${more} more available.` : ""}`}1177 description="Import projects and conversations from your selected computers."
1344 onBack={onBack}
1345 backDisabled={isImporting}
1346 >1178 >
1347 {scanLimitNotice}1179 <ScrollArea
1348 <div className="mt-6 border-y border-border">1180 scrollFade
1349 {recent.slice(0, 4).map((candidate) => (1181 className="mt-5 h-auto max-h-80 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
1350 <div1182 >
1351 key={candidate.path}1183 <div className="space-y-5 pr-3">
1352 className="flex min-h-12 items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0"1184 {scans.map((scan) => {
1353 >1185 const groupCandidates = candidates.filter(
1354 <CheckIcon className="size-3.5 shrink-0 text-success-foreground" />1186 (candidate) => candidate.environmentId === scan.environmentId,
1355 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">1187 );
1356 {candidate.path}1188 const label =
1357 </span>1189 environments.find((environment) => environment.environmentId === scan.environmentId)
1358 <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block">1190 ?.label ?? "Computer";
1359 {candidate.sources.map(formatSource).join(", ")}1191 return (
1360 </span>1192 <fieldset
1361 </div>1193 key={scan.environmentId}
1362 ))}1194 className="min-w-0 space-y-1.5"
1363 {recent.length > 4 ? (1195 disabled={isImporting}
1364 <p className="px-1 py-3 text-xs text-muted-foreground">1196 >
1365 {recent.length - 4} more projects1197 <legend className="mb-2 text-sm font-medium">{label}</legend>
1366 </p>1198 {scan.isPending && scan.data === null ? (
1367 ) : null}1199 <div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
1368 </div>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>
1369 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}1260 {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">
1371 <Button1262 <Button
1372 variant="ghost-muted"1263 variant="ghost-muted"
1373 disabled={isImporting}1264 disabled={isImporting}
1374 onClick={importError ? finishAfterImport : () => void onDone()}1265 onClick={importError ? finishAfterImport : () => void onDone()}
1375 >1266 >
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"}`}
1377 </Button>1277 </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>
1391 </div>1278 </div>
1392 </StepShell>1279 </StepShell>
1393 );1280 );
...@@ -1398,31 +1285,15 @@ function ImportStep({...@@ -1398,31 +1285,15 @@ function ImportStep({
1398function StepShell({1285function StepShell({
1399 title,1286 title,
1400 description,1287 description,
1401 onBack,
1402 backDisabled = false,
1403 children,1288 children,
1404}: {1289}: {
1405 readonly title: string;1290 readonly title: string;
1406 readonly description?: string;1291 readonly description?: string;
1407 readonly onBack?: () => void;
1408 readonly backDisabled?: boolean;
1409 readonly children?: React.ReactNode;1292 readonly children?: React.ReactNode;
1410}) {1293}) {
1411 return (1294 return (
1412 <>1295 <>
1413 {onBack ? (1296 <h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
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>
1426 {description ? (1297 {description ? (
1427 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{description}</p>1298 <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
1428 ) : null}1299 ) : null}
...@@ -1447,7 +1318,7 @@ function CommandBlock({...@@ -1447,7 +1318,7 @@ function CommandBlock({
1447 return (1318 return (
1448 <div1319 <div
1449 className={cn(1320 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",
1451 prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",1322 prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
1452 className,1323 className,
1453 )}1324 )}
...@@ -1467,7 +1338,3 @@ function CommandBlock({...@@ -1467,7 +1338,3 @@ function CommandBlock({
1467 </div>1338 </div>
1468 );1339 );
1469}1340}
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";...@@ -29,7 +29,7 @@ import { RadioGroup } from "../ui/radio-group";
29import { toastManager } from "../ui/toast";29import { toastManager } from "../ui/toast";
30import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";30import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
31import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";31import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
32import { AnimatedHeight } from "../AnimatedHeight";32import { WizardPanel } from "../ui/wizard";
33import {33import {
34 ADD_PROVIDER_WIZARD_STEPS,34 ADD_PROVIDER_WIZARD_STEPS,
35 resolveWizardNavigation,35 resolveWizardNavigation,
...@@ -246,173 +246,168 @@ export function AddProviderInstanceDialog({...@@ -246,173 +246,168 @@ export function AddProviderInstanceDialog({
246 />246 />
247 </DialogHeader>247 </DialogHeader>
248248
249 <div249 <WizardPanel>
250 data-slot="dialog-panel"250 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
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"251 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
252 >252 Driver
253 <AnimatedHeight>253 </div>
254 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>254 <RadioGroup
255 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">255 value={driver}
256 Driver256 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
257 </div>257 aria-labelledby="add-instance-driver-label"
258 <RadioGroup258 className="grid grid-cols-1 gap-2 sm:grid-cols-2"
259 value={driver}259 >
260 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}260 {DRIVER_OPTIONS.map((option) => {
261 aria-labelledby="add-instance-driver-label"261 const IconComponent = option.icon;
262 className="grid grid-cols-1 gap-2 sm:grid-cols-2"262 return (
263 >263 <RadioPrimitive.Root
264 {DRIVER_OPTIONS.map((option) => {264 key={option.value}
265 const IconComponent = option.icon;265 value={option.value}
266 return (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 <RadioPrimitive.Root267 >
268 key={option.value}268 <IconComponent className="size-4 shrink-0" aria-hidden />
269 value={option.value}269 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
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"270 {option.label}
271 >271 </span>
272 <IconComponent className="size-4 shrink-0" aria-hidden />272 <RadioPrimitive.Indicator
273 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">273 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
274 {option.label}274 aria-hidden
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 )}
300 >275 >
301 <IconComponent276 <CheckIcon className="size-3.5 shrink-0" />
302 className="size-4 shrink-0 text-muted-foreground"277 </RadioPrimitive.Indicator>
303 aria-hidden278 {option.badgeLabel ? (
304 />
305 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
306 {option.label}
307 </span>
308 <Badge variant="warning" size="sm">279 <Badge variant="warning" size="sm">
309 Coming Soon280 {option.badgeLabel}
310 </Badge>281 </Badge>
311 </RadioPrimitive.Root>282 ) : null}
312 );283 </RadioPrimitive.Root>
313 })}284 );
314 </RadioGroup>285 })}
315 </div>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")}>313 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
318 <span className="text-xs font-medium text-foreground">Label</span>314 <span className="text-xs font-medium text-foreground">Label</span>
319 <Input315 <Input
320 className="bg-background"316 className="bg-background"
321 placeholder="e.g. Work"317 placeholder="e.g. Work"
322 value={label}318 value={label}
323 onChange={(event) => setLabel(event.target.value)}319 onChange={(event) => setLabel(event.target.value)}
324 />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 ) : (
325 <span className="text-[11px] text-muted-foreground">340 <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 '_'.
327 </span>342 </span>
328 </label>343 )}
344 </label>
329345
330 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>346 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
331 <span className="text-xs font-medium text-foreground">Instance ID</span>347 <span className="text-xs font-medium text-foreground">Accent color</span>
332 <Input348 <div className="flex min-w-0 flex-wrap items-center gap-2">
333 className="bg-background"349 <input
334 placeholder={`${driver}_work`}350 type="color"
335 value={instanceId}351 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
336 onChange={(event) => {352 onChange={(event) => setAccentColor(event.target.value)}
337 setInstanceIdOverride(event.target.value);353 aria-label="Provider instance accent color"
338 }}354 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
339 aria-invalid={showInstanceIdError}
340 />355 />
341 {showInstanceIdError ? (356 <div className="flex flex-wrap gap-1.5">
342 <span className="text-[11px] text-destructive">{instanceIdError}</span>357 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
343 ) : (358 const selected = accentColor.toLowerCase() === swatch;
344 <span className="text-[11px] text-muted-foreground">359 return (
345 Routing key used by threads and sessions. Letters, digits, '-', or '_'.360 <button
346 </span>361 key={swatch}
347 )}362 type="button"
348 </label>363 className={cn(
349364 "size-6 cursor-pointer rounded-full border transition",
350 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>365 selected
351 <span className="text-xs font-medium text-foreground">Accent color</span>366 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
352 <div className="flex min-w-0 flex-wrap items-center gap-2">367 : "border-black/10 hover:scale-105 dark:border-white/20",
353 <input368 )}
354 type="color"369 style={{ backgroundColor: swatch }}
355 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}370 onClick={() => setAccentColor(swatch)}
356 onChange={(event) => setAccentColor(event.target.value)}371 aria-label={`Use ${swatch} accent`}
357 aria-label="Provider instance accent color"372 />
358 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"373 );
359 />374 })}
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}
391 </div>375 </div>
392 <span className="text-[11px] text-muted-foreground">376 {accentColor ? (
393 Optional marker shown in the picker.377 <Button
394 </span>378 type="button"
379 size="xs"
380 variant="ghost"
381 className="text-muted-foreground"
382 onClick={() => setAccentColor("")}
383 >
384 Clear
385 </Button>
386 ) : null}
395 </div>387 </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 ? (393 {driverSettingsFields.length > 0 ? (
398 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>394 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
399 <ProviderSettingsForm395 <ProviderSettingsForm
400 definition={driverOption}396 definition={driverOption}
401 value={configDraft}397 value={configDraft}
402 idPrefix={`add-provider-${driver}`}398 idPrefix={`add-provider-${driver}`}
403 variant="dialog"399 variant="dialog"
404 onChange={setConfigDraft}400 onChange={setConfigDraft}
405 />401 />
406 </div>402 </div>
407 ) : wizardStep === 2 ? (403 ) : wizardStep === 2 ? (
408 <div className="grid gap-2">404 <div className="grid gap-2">
409 <p className="text-sm text-muted-foreground">405 <p className="text-sm text-muted-foreground">
410 This driver has no required configuration. You can add the instance now.406 This driver has no required configuration. You can add the instance now.
411 </p>407 </p>
412 </div>408 </div>
413 ) : null}409 ) : null}
414 </AnimatedHeight>410 </WizardPanel>
415 </div>
416411
417 <DialogFooter variant="bare">412 <DialogFooter variant="bare">
418 <Button413 <Button
apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { Children, isValidElement, type ReactElement } from "react";1import { Children, isValidElement, type ReactElement } from "react";
2import { describe, expect, it, vi } from "vite-plus/test";2import { describe, expect, it, vi } from "vite-plus/test";
33
4import { WizardSteps } from "../ui/wizard";
4import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic";5import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic";
5import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";6import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
67
...@@ -25,7 +26,7 @@ function renderStepButtons(...@@ -25,7 +26,7 @@ function renderStepButtons(
25 onNavigation,26 onNavigation,
26 });27 });
2728
28 return Children.toArray(header.props.children)29 return Children.toArray(WizardSteps(header.props).props.children)
29 .filter((child): child is ReactElement<StepListItemProps> => isValidElement(child))30 .filter((child): child is ReactElement<StepListItemProps> => isValidElement(child))
30 .map((item) => item.props.children);31 .map((item) => item.props.children);
31}32}
apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx+13-51
...@@ -1,6 +1,4 @@...@@ -1,6 +1,4 @@
1import { CheckIcon } from "lucide-react";1import { WizardSteps } from "../ui/wizard";
2
3import { cn } from "../../lib/utils";
4import {2import {
5 ADD_PROVIDER_WIZARD_STEPS,3 ADD_PROVIDER_WIZARD_STEPS,
6 resolveWizardNavigation,4 resolveWizardNavigation,
...@@ -21,53 +19,17 @@ export function AddProviderInstanceWizardSteps({...@@ -21,53 +19,17 @@ export function AddProviderInstanceWizardSteps({
21 onNavigation,19 onNavigation,
22}: AddProviderInstanceWizardStepsProps) {20}: AddProviderInstanceWizardStepsProps) {
23 return (21 return (
24 <ol22 <WizardSteps
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"23 steps={ADD_PROVIDER_WIZARD_STEPS}
26 role="list"24 currentStep={currentStep}
27 >25 summaries={summaries}
28 {ADD_PROVIDER_WIZARD_STEPS.map((step, index) => (26 onStepChange={(requestedStep) =>
29 <li key={step} className="min-w-0">27 onNavigation(
30 <button28 resolveWizardNavigation(currentStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, {
31 type="button"29 instanceIdError,
32 className={cn(30 }),
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",31 )
34 index === currentStep &&32 }
35 "bg-card text-foreground shadow-xs ring-1 ring-black/5 hover:bg-card dark:shadow-none dark:ring-white/5",33 />
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>
72 );34 );
73}35}
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", () => {...@@ -204,204 +204,3 @@ describe("theme failure handling", () => {
204 }204 }
205 });205 });
206});206});
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 {...@@ -99,13 +99,6 @@ function themeHalvesSignature(halves: ThemeHalves | null): string {
99 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;99 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
100}100}
101101
102function isOnboardingThemeActive(): boolean {
103 return (
104 typeof document !== "undefined" &&
105 document.documentElement.dataset?.onboardingSurface !== undefined
106 );
107}
108
109const THEME_COLOR_META_NAME = "theme-color";102const THEME_COLOR_META_NAME = "theme-color";
110const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;103const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
111104
...@@ -300,22 +293,15 @@ function resolveBrowserChromeSurface(): HTMLElement {...@@ -300,22 +293,15 @@ function resolveBrowserChromeSurface(): HTMLElement {
300293
301export function syncBrowserChromeTheme() {294export function syncBrowserChromeTheme() {
302 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;295 if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
303 const onboardingActive = isOnboardingThemeActive();
304 const rootStyles = getComputedStyle(document.documentElement);296 const rootStyles = getComputedStyle(document.documentElement);
305 const themeChromeColor =297 const themeChromeColor = document.documentElement.dataset.themeId
306 !onboardingActive && document.documentElement.dataset.themeId298 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))
307 ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background"))299 : null;
308 : null;
309 const surfaceColor = normalizeThemeColor(300 const surfaceColor = normalizeThemeColor(
310 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,301 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
311 );302 );
312 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);303 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
313 // Dark onboarding pins a true-black canvas; light onboarding uses the304 const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor;
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);
319 if (!backgroundColor) return;305 if (!backgroundColor) return;
320306
321 document.documentElement.style.backgroundColor = backgroundColor;307 document.documentElement.style.backgroundColor = backgroundColor;
...@@ -336,13 +322,8 @@ export function syncBrowserChromeTheme() {...@@ -336,13 +322,8 @@ export function syncBrowserChromeTheme() {
336322
337function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {323function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {
338 if (typeof document === "undefined" || typeof window === "undefined") return;324 if (typeof document === "undefined" || typeof window === "undefined") return;
339 const onboardingActive = isOnboardingThemeActive();
340 // Keep the editor's draft visible until an explicit refresh restores the selection.325 // Keep the editor's draft visible until an explicit refresh restores the selection.
341 if (326 if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) {
342 preservePreview &&
343 !onboardingActive &&
344 document.documentElement.dataset?.themeId === THEME_PREVIEW_ID
345 ) {
346 return;327 return;
347 }328 }
348 const appearanceMode = readAppearanceModePreference(theme);329 const appearanceMode = readAppearanceModePreference(theme);
...@@ -370,11 +351,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -370,11 +351,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
370 appearanceMode,351 appearanceMode,
371 themeHalves,352 themeHalves,
372 );353 );
373 // Onboarding follows the saved light/dark appearance but never applies a354 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
374 // custom palette, so the wizard keeps its fixed neutral tokens.
375 if (!onboardingActive) {
376 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
377 }
378 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");355 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
379 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };356 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
380 syncBrowserChromeTheme();357 syncBrowserChromeTheme();
...@@ -388,32 +365,6 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -388,32 +365,6 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
388 }365 }
389}366}
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
417export async function syncDesktopThemePreference(368export async function syncDesktopThemePreference(
418 bridge: DesktopThemeBridge,369 bridge: DesktopThemeBridge,
419 theme: Theme,370 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...@@ -609,6 +609,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
609}609}
610610
611@layer components {611@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
612 /* Each maintainer palette gives the same line art its own material: rose624 /* Each maintainer palette gives the same line art its own material: rose
613 vellum, forest drafting paper, marine cyanotype, copper, and violet ink.625 vellum, forest drafting paper, marine cyanotype, copper, and violet ink.
614 These colors stay deliberately deep at the top edge so the white stage626 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=""]) {...@@ -1184,43 +1196,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
1184 --terminal-selection-background: var(--app-theme-terminal-selection-background);1196 --terminal-selection-background: var(--app-theme-terminal-selection-background);
1185}1197}
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
1224/* Theme-token dependency probes are restored synchronously, before paint. Keep1199/* Theme-token dependency probes are restored synchronously, before paint. Keep
1225 transitions from observing the temporary sentinel color in between. */1200 transitions from observing the temporary sentinel color in between. */
1226html[data-theme-token-probe],1201html[data-theme-token-probe],
...@@ -1424,8 +1399,7 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] {...@@ -1424,8 +1399,7 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] {
14241399
1425/* Recompute contrast wherever a subtree owns its own semantic color palette. */1400/* Recompute contrast wherever a subtree owns its own semantic color palette. */
1426:root,1401:root,
1427[data-app-sidebar],1402[data-app-sidebar] {
1428[data-onboarding-surface] {
1429 --contrast-toolbar-foreground: color-mix(1403 --contrast-toolbar-foreground: color-mix(
1430 in oklab,1404 in oklab,
1431 color-mix(1405 color-mix(
apps/web/src/onboarding/projectImport.logic.test.ts+21
...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 partitionOnboardingProjects,5 partitionOnboardingProjects,
6 onboardingProjectKey,
6 resolveOnboardingLandingProject,7 resolveOnboardingLandingProject,
7 resolveOnboardingProjectId,8 resolveOnboardingProjectId,
8} from "./projectImport.logic";9} from "./projectImport.logic";
...@@ -243,3 +244,23 @@ describe("resolveOnboardingLandingProject", () => {...@@ -243,3 +244,23 @@ describe("resolveOnboardingLandingProject", () => {
243 ).toBe("current");244 ).toBe("current");
244 });245 });
245});246});
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...@@ -4,8 +4,8 @@ import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3
4const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;4const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
55
6/** Existing projects still need their agent history imported, so every scan candidate is offered. */6/** Existing projects still need their agent history imported, so every scan candidate is offered. */
7export function partitionOnboardingProjects(7export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
8 candidates: ReadonlyArray<AgentSessionProjectCandidate>,8 candidates: ReadonlyArray<T>,
9 now = Date.now(),9 now = Date.now(),
10) {10) {
11 const cutoff = now - RECENT_PROJECT_WINDOW_MS;11 const cutoff = now - RECENT_PROJECT_WINDOW_MS;
...@@ -53,3 +53,8 @@ export function resolveOnboardingLandingProject<T>(...@@ -53,3 +53,8 @@ export function resolveOnboardingLandingProject<T>(
53 }53 }
54 return undefined;54 return undefined;
55}55}
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() {...@@ -124,15 +124,23 @@ function RootRouteView() {
124 );124 );
125 }125 }
126126
127 // The welcome wizard is full-screen like /pair, but keeps toasts so its127 // Show onboarding over the workspace, keeping automatic thread navigation
128 // connect/import actions can report failures.128 // and other startup dialogs suspended until setup finishes.
129 if (pathname === "/welcome") {129 if (pathname === "/welcome") {
130 return (130 return (
131 <ToastProvider>131 <ToastProvider>
132 <DocumentTitleSync />132 <AnchoredToastProvider>
133 <ContrastAppearanceSync />133 <DocumentTitleSync />
134 <FontAppearanceSync />134 <ContrastAppearanceSync />
135 <Outlet />135 <EnvironmentThemeSync />
136 <GlassAppearanceSync />
137 <FontAppearanceSync />
138 <CommandPalette>
139 <AppSidebarLayout>
140 <Outlet />
141 </AppSidebarLayout>
142 </CommandPalette>
143 </AnchoredToastProvider>
136 </ToastProvider>144 </ToastProvider>
137 );145 );
138 }146 }
apps/web/src/routes/_chat.index.tsx+2-31
...@@ -2,9 +2,9 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";...@@ -2,9 +2,9 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { scopeProjectRef } from "@t3tools/client-runtime/environment";2import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { createFileRoute, Link } from "@tanstack/react-router";3import { createFileRoute, Link } from "@tanstack/react-router";
4import { LinkIcon, PlusIcon } from "lucide-react";4import { 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";
8import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";8import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
9import { Button } from "../components/ui/button";9import { Button } from "../components/ui/button";
10import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";10import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
...@@ -107,35 +107,6 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {...@@ -107,35 +107,6 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
107 );107 );
108}108}
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
139export const Route = createFileRoute("/_chat/")({110export const Route = createFileRoute("/_chat/")({
140 component: ChatIndexRouteView,111 component: ChatIndexRouteView,
141});112});
apps/web/src/routes/welcome.tsx+25-18
...@@ -1,14 +1,11 @@...@@ -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";
3import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";5import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";
4import { useNewThreadHandler } from "../hooks/useHandleNewThread";6import { useNewThreadHandler } from "../hooks/useHandleNewThread";
57
6/**8/** Onboarding overlays the workspace. Visiting /welcome reopens setup. */
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 */
12export const Route = createFileRoute("/welcome")({9export const Route = createFileRoute("/welcome")({
13 beforeLoad: ({ context }) => {10 beforeLoad: ({ context }) => {
14 const { authGateState } = context;11 const { authGateState } = context;
...@@ -22,6 +19,10 @@ export const Route = createFileRoute("/welcome")({...@@ -22,6 +19,10 @@ export const Route = createFileRoute("/welcome")({
22function WelcomeRouteView() {19function WelcomeRouteView() {
23 const { authGateState } = Route.useRouteContext();20 const { authGateState } = Route.useRouteContext();
24 const navigate = useNavigate();21 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);
25 const openNewThread = useNewThreadHandler();26 const openNewThread = useNewThreadHandler();
26 // An authenticated gate means a primary server is serving this app —27 // An authenticated gate means a primary server is serving this app —
27 // desktop, `npx t3`, or a dev server — and that server is "this machine"28 // desktop, `npx t3`, or a dev server — and that server is "this machine"
...@@ -29,17 +30,23 @@ function WelcomeRouteView() {...@@ -29,17 +30,23 @@ function WelcomeRouteView() {
29 // local server to offer.30 // local server to offer.
30 const localAvailable = authGateState.status === "authenticated";31 const localAvailable = authGateState.status === "authenticated";
31 return (32 return (
32 <WelcomeWizard33 <>
33 localAvailable={localAvailable}34 <NoProjectsHero />
34 onDone={(projectRef) => {35 {isWelcomeRoute && !dismissed ? (
35 if (projectRef !== undefined) {36 <WelcomeWizard
36 void openNewThread(projectRef, { replace: true }).catch(() => {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 }
37 void navigate({ to: "/", replace: true });46 void navigate({ to: "/", replace: true });
38 });47 }}
39 return;48 />
40 }49 ) : null}
41 void navigate({ to: "/", replace: true });50 </>
42 }}
43 />
44 );51 );
45}52}
docs/user/welcome-wizard.md+18-8
...@@ -3,18 +3,28 @@...@@ -3,18 +3,28 @@
3T3 Code shows a setup flow when you open a new installation or connect to the3T3 Code shows a setup flow when you open a new installation or connect to the
4hosted app for the first time. Existing workspaces skip this flow.4hosted app for the first time. Existing workspaces skip this flow.
55
6## Choose a connection6## 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.
10- **T3 Connect** connects computers that are signed in to your account. Run15- **T3 Connect** connects computers that are signed in to your account. Run
11 `npx t3 connect` on each computer you want to add, then start T3 Code or run16 `npx t3 connect` on each computer you want to add, then start T3 Code or run
12 `npx t3 serve` so the computer stays available.17 `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.
14 Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and19 Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and
15 paste the pairing link. You can also run `npx t3 serve --host <address>` and20 paste the pairing link. You can also run `npx t3 serve --host <address>` and
16 use `npx t3 pair` when the server is already reachable on your network.21 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
18If T3 Code cannot confirm the workspace during startup, the setup flow shows28If T3 Code cannot confirm the workspace during startup, the setup flow shows
19**Still connecting** instead of opening the app. Select **Reload** to try again.29**Still connecting** instead of opening the app. Select **Reload** to try again.
2030
...@@ -24,7 +34,7 @@ unreadable settings with defaults....@@ -24,7 +34,7 @@ unreadable settings with defaults.
2434
25## Check your agents35## Check your agents
2636
27T3 Code checks the selected computer for Claude Code and Codex. If an agent is37T3 Code checks each selected computer for Claude Code and Codex. If an agent is
28not installed or signed in, select its action to open a terminal with the38not installed or signed in, select its action to open a terminal with the
29correct command ready to run. Install uses the vendor's standalone installer,39correct command ready to run. Install uses the vendor's standalone installer,
30which does not need Node or npm and keeps **Update now** working in Settings.40which 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....@@ -37,7 +47,7 @@ terminal metadata while the terminal process can use them.
37## Import your projects47## Import your projects
3848
39T3 Code finds directories that Claude Code or Codex has used. The default49T3 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
41to include older projects or change the selection.51to include older projects or change the selection.
4252
43A large or malformed history can reach the scan limit. T3 Code keeps the53A 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....@@ -60,5 +70,5 @@ with up to 100,000 input records. Run import again to continue a large batch.
60Completed conversations are not imported again. You can continue without the70Completed conversations are not imported again. You can continue without the
61remaining history.71remaining history.
6272
63You can skip agent setup and project import. Select **Back** to return to a73You can continue without configuring agents or importing projects, or return to an earlier step
64previous step.74using the setup progress bar. Navigation pauses while an import is running.