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