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