| author | |
| committer | |
| log | de251fc2971a884cb5b1305ba4daf309dc8cccb0 |
| tree | 52c240bfe326575ce4f81b70d285b6d27208388f |
| parent | a727d1d97690c9bb12cee5760e91cfd1aa7c017d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>
Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com>
Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>3 files changed, 253 insertions(+), 34 deletions(-)
apps/web/src/components/onboarding/WelcomeWizard.test.tsx created+209| ... | ... | @@ -0,0 +1,209 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | import { act } from "react"; | |
| 3 | import { createRoot, type Root } from "react-dom/client"; | |
| 4 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | const mocks = vi.hoisted(() => ({ | |
| 8 | importThreads: vi.fn(), | |
| 9 | createProject: vi.fn(), | |
| 10 | complete: vi.fn(), | |
| 11 | refresh: vi.fn(), | |
| 12 | toast: vi.fn(), | |
| 13 | projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>, | |
| 14 | })); | |
| 15 | vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" })); | |
| 16 | vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } })); | |
| 17 | vi.mock("../../state/use-atom-command", () => ({ | |
| 18 | useAtomCommand: (command: string) => | |
| 19 | command === "import" | |
| 20 | ? mocks.importThreads | |
| 21 | : command === "create" | |
| 22 | ? mocks.createProject | |
| 23 | : mocks.refresh, | |
| 24 | })); | |
| 25 | vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => mocks.complete })); | |
| 26 | vi.mock("../../state/entities", () => ({ | |
| 27 | useProjects: () => mocks.projects, | |
| 28 | readProjects: () => mocks.projects, | |
| 29 | })); | |
| 30 | vi.mock("../../state/environments", () => { | |
| 31 | const environment = { | |
| 32 | environmentId: "test-env", | |
| 33 | label: "Computer", | |
| 34 | connection: { phase: "connected" }, | |
| 35 | }; | |
| 36 | return { | |
| 37 | useEnvironments: () => ({ environments: [environment] }), | |
| 38 | usePrimaryEnvironment: () => environment, | |
| 39 | }; | |
| 40 | }); | |
| 41 | vi.mock("../../state/server", () => ({ | |
| 42 | serverEnvironment: { | |
| 43 | providersValueAtom: () => [], | |
| 44 | configValueAtom: () => null, | |
| 45 | refreshProviders: "refresh", | |
| 46 | }, | |
| 47 | })); | |
| 48 | vi.mock("@effect/atom-react", () => ({ useAtomValue: (value: unknown) => value })); | |
| 49 | vi.mock("../../onboarding/useProjectScans", () => ({ | |
| 50 | useProjectScans: () => [ | |
| 51 | { | |
| 52 | environmentId: "test-env", | |
| 53 | isPending: false, | |
| 54 | error: null, | |
| 55 | refresh: mocks.refresh, | |
| 56 | data: { | |
| 57 | truncated: false, | |
| 58 | candidates: [ | |
| 59 | { | |
| 60 | path: "/project", | |
| 61 | title: "project", | |
| 62 | projectId: "test-project", | |
| 63 | threadCount: 29, | |
| 64 | lastActiveAt: new Date().toISOString(), | |
| 65 | sources: ["codex"], | |
| 66 | }, | |
| 67 | ], | |
| 68 | }, | |
| 69 | }, | |
| 70 | ], | |
| 71 | })); | |
| 72 | vi.mock("../../connection/onboarding", () => ({ connectPairing: vi.fn() })); | |
| 73 | vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); | |
| 74 | vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); | |
| 75 | vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); | |
| 76 | vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); | |
| 77 | vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ | |
| 78 | CloudEnvironmentConnectRows: () => null, | |
| 79 | })); | |
| 80 | vi.mock("../ui/toast", () => ({ | |
| 81 | toastManager: { add: mocks.toast, close: vi.fn(), update: vi.fn() }, | |
| 82 | })); | |
| 83 | ||
| 84 | import { WelcomeWizard } from "./WelcomeWizard"; | |
| 85 | ||
| 86 | let root: Root; | |
| 87 | let container: HTMLDivElement; | |
| 88 | ||
| 89 | beforeEach(() => { | |
| 90 | vi.clearAllMocks(); | |
| 91 | vi.stubGlobal( | |
| 92 | "ResizeObserver", | |
| 93 | class { | |
| 94 | observe() {} | |
| 95 | unobserve() {} | |
| 96 | disconnect() {} | |
| 97 | }, | |
| 98 | ); | |
| 99 | Object.defineProperty(Element.prototype, "getAnimations", { | |
| 100 | configurable: true, | |
| 101 | value: () => [], | |
| 102 | }); | |
| 103 | mocks.projects = [{ id: "test-project", environmentId: "test-env", workspaceRoot: "/project" }]; | |
| 104 | mocks.complete.mockResolvedValue(undefined); | |
| 105 | mocks.refresh.mockResolvedValue(undefined); | |
| 106 | mocks.importThreads.mockResolvedValue({ | |
| 107 | _tag: "Success", | |
| 108 | value: { importedCount: 28, skippedCount: 1 }, | |
| 109 | }); | |
| 110 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 111 | container = document.createElement("div"); | |
| 112 | document.body.append(container); | |
| 113 | root = createRoot(container); | |
| 114 | }); | |
| 115 | afterEach(async () => { | |
| 116 | await act(async () => root.unmount()); | |
| 117 | container.remove(); | |
| 118 | }); | |
| 119 | ||
| 120 | async function click(label: string) { | |
| 121 | const button = [...document.querySelectorAll("button")].find( | |
| 122 | (element) => element.textContent?.trim() === label, | |
| 123 | ); | |
| 124 | expect(button, `button ${label}`).toBeDefined(); | |
| 125 | await act(async () => button!.click()); | |
| 126 | } | |
| 127 | ||
| 128 | it("enters the workspace after a partial import and warns after navigation finishes", async () => { | |
| 129 | let finishNavigation = () => {}; | |
| 130 | const navigation = new Promise<void>((resolve) => { | |
| 131 | finishNavigation = resolve; | |
| 132 | }); | |
| 133 | const onDone = vi.fn(() => navigation); | |
| 134 | await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />)); | |
| 135 | await click("Continue"); | |
| 136 | await click("Continue"); | |
| 137 | await click("Import 1 project"); | |
| 138 | expect(onDone).toHaveBeenCalledWith({ | |
| 139 | environmentId: EnvironmentId.make("test-env"), | |
| 140 | projectId: ProjectId.make("test-project"), | |
| 141 | }); | |
| 142 | expect(mocks.toast).not.toHaveBeenCalled(); | |
| 143 | await act(async () => finishNavigation()); | |
| 144 | expect(mocks.toast).toHaveBeenCalledWith( | |
| 145 | expect.objectContaining({ | |
| 146 | type: "warning", | |
| 147 | description: "Imported 28 threads. 1 thread could not be imported.", | |
| 148 | }), | |
| 149 | ); | |
| 150 | expect(mocks.toast.mock.invocationCallOrder[0]).toBeGreaterThan( | |
| 151 | onDone.mock.invocationCallOrder[0]!, | |
| 152 | ); | |
| 153 | }); | |
| 154 | ||
| 155 | it.each([ | |
| 156 | [0, 0, null], | |
| 157 | [29, 0, null], | |
| 158 | [1, 0, null], | |
| 159 | [0, 1, "1 thread could not be imported."], | |
| 160 | [0, 2, "2 threads could not be imported."], | |
| 161 | ] as const)( | |
| 162 | "finishes setup with %i imported and %i skipped threads", | |
| 163 | async (importedCount, skippedCount, warning) => { | |
| 164 | mocks.importThreads.mockResolvedValue({ | |
| 165 | _tag: "Success", | |
| 166 | value: { importedCount, skippedCount }, | |
| 167 | }); | |
| 168 | const onDone = vi.fn(); | |
| 169 | await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />)); | |
| 170 | await click("Continue"); | |
| 171 | await click("Continue"); | |
| 172 | await click("Import 1 project"); | |
| 173 | expect(onDone).toHaveBeenCalledOnce(); | |
| 174 | if (warning === null && importedCount > 0) { | |
| 175 | expect(mocks.toast).toHaveBeenCalledWith({ | |
| 176 | type: "success", | |
| 177 | title: `Imported ${importedCount} ${importedCount === 1 ? "thread" : "threads"}`, | |
| 178 | }); | |
| 179 | } else if (warning === null) { | |
| 180 | expect(mocks.toast).not.toHaveBeenCalled(); | |
| 181 | } else { | |
| 182 | expect(mocks.toast).toHaveBeenCalledWith( | |
| 183 | expect.objectContaining({ type: "warning", description: warning }), | |
| 184 | ); | |
| 185 | } | |
| 186 | }, | |
| 187 | ); | |
| 188 | ||
| 189 | it("keeps setup open when saving completion fails and preserves the import warning on retry", async () => { | |
| 190 | mocks.complete.mockRejectedValueOnce(new Error("settings unavailable")); | |
| 191 | const onDone = vi.fn(); | |
| 192 | await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />)); | |
| 193 | await click("Continue"); | |
| 194 | await click("Continue"); | |
| 195 | await click("Import 1 project"); | |
| 196 | expect(onDone).not.toHaveBeenCalled(); | |
| 197 | expect(mocks.toast).toHaveBeenCalledWith( | |
| 198 | expect.objectContaining({ type: "error", title: "Could not finish setup" }), | |
| 199 | ); | |
| 200 | await click("Do not import projects"); | |
| 201 | expect(onDone).toHaveBeenCalledOnce(); | |
| 202 | expect(mocks.importThreads).toHaveBeenCalledOnce(); | |
| 203 | expect(mocks.toast).toHaveBeenLastCalledWith( | |
| 204 | expect.objectContaining({ | |
| 205 | type: "warning", | |
| 206 | description: "Imported 28 threads. 1 thread could not be imported.", | |
| 207 | }), | |
| 208 | ); | |
| 209 | }); |
apps/web/src/components/onboarding/WelcomeWizard.tsx+39-29| ... | ... | @@ -99,7 +99,7 @@ export function WelcomeWizard({ |
| 99 | 99 | }: { |
| 100 | 100 | /** Whether this client is authenticated to the server serving the app. */ |
| 101 | 101 | readonly localAvailable: boolean; |
| 102 | readonly onDone: (projectRef?: ScopedProjectRef) => void; | |
| 102 | readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>; | |
| 103 | 103 | }) { |
| 104 | 104 | const completeOnboarding = useCompleteOnboarding(); |
| 105 | 105 | const [step, setStep] = useState<WizardStep>("connection"); |
| ... | ... | @@ -141,7 +141,7 @@ export function WelcomeWizard({ |
| 141 | 141 | }; |
| 142 | 142 | const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; |
| 143 | 143 | const finish = useCallback( |
| 144 | (projectRef?: ScopedProjectRef) => { | |
| 144 | (projectRef?: ScopedProjectRef, importWarning?: string, importedThreadCount = 0) => { | |
| 145 | 145 | if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; |
| 146 | 146 | if (completionErrorToastIdRef.current !== null) { |
| 147 | 147 | toastManager.close(completionErrorToastIdRef.current); |
| ... | ... | @@ -149,12 +149,25 @@ export function WelcomeWizard({ |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 151 | const completion = completeOnboarding() |
| 152 | .then(() => { | |
| 152 | .then(async () => { | |
| 153 | 153 | if (completionErrorToastIdRef.current !== null) { |
| 154 | 154 | toastManager.close(completionErrorToastIdRef.current); |
| 155 | 155 | completionErrorToastIdRef.current = null; |
| 156 | 156 | } |
| 157 | onDone(projectRef); | |
| 157 | await onDone(projectRef); | |
| 158 | if (importWarning) { | |
| 159 | toastManager.add({ | |
| 160 | type: "warning", | |
| 161 | title: "Some history was not imported", | |
| 162 | description: importWarning, | |
| 163 | timeout: 0, | |
| 164 | }); | |
| 165 | } else if (importedThreadCount > 0) { | |
| 166 | toastManager.add({ | |
| 167 | type: "success", | |
| 168 | title: `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}`, | |
| 169 | }); | |
| 170 | } | |
| 158 | 171 | return true; |
| 159 | 172 | }) |
| 160 | 173 | .catch(() => { |
| ... | ... | @@ -952,14 +965,19 @@ function ImportStep({ |
| 952 | 965 | readonly scans: ReturnType<typeof useProjectScans>; |
| 953 | 966 | readonly isImporting: boolean; |
| 954 | 967 | readonly setIsImporting: (value: boolean) => void; |
| 955 | readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>; | |
| 968 | readonly onDone: ( | |
| 969 | projectRef?: ScopedProjectRef, | |
| 970 | importWarning?: string, | |
| 971 | importedThreadCount?: number, | |
| 972 | ) => Promise<boolean>; | |
| 956 | 973 | }) { |
| 957 | 974 | const { environments } = useEnvironments(); |
| 958 | 975 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); |
| 959 | 976 | const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); |
| 960 | 977 | const projects = useProjects(); |
| 961 | 978 | const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null); |
| 962 | const [importError, setImportError] = useState(""); | |
| 979 | const importWarningRef = useRef(""); | |
| 980 | const importedThreadCountRef = useRef(0); | |
| 963 | 981 | const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null); |
| 964 | 982 | // Keep project creation attempts separate from completed history imports so both can retry. |
| 965 | 983 | const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>()); |
| ... | ... | @@ -988,9 +1006,11 @@ function ImportStep({ |
| 988 | 1006 | ) |
| 989 | 1007 | ) { |
| 990 | 1008 | setLandingProject(null); |
| 991 | void onDone(landingProject).then((completed) => { | |
| 992 | if (!completed) setIsImporting(false); | |
| 993 | }); | |
| 1009 | void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then( | |
| 1010 | (completed) => { | |
| 1011 | if (!completed) setIsImporting(false); | |
| 1012 | }, | |
| 1013 | ); | |
| 994 | 1014 | } |
| 995 | 1015 | }, [landingProject, onDone, projects, setIsImporting]); |
| 996 | 1016 | |
| ... | ... | @@ -1020,7 +1040,7 @@ function ImportStep({ |
| 1020 | 1040 | importedProjectsRef.current, |
| 1021 | 1041 | ); |
| 1022 | 1042 | if (projectRef === undefined) { |
| 1023 | void onDone(); | |
| 1043 | void onDone(undefined, importWarningRef.current, importedThreadCountRef.current); | |
| 1024 | 1044 | return; |
| 1025 | 1045 | } |
| 1026 | 1046 | setIsImporting(true); |
| ... | ... | @@ -1034,7 +1054,8 @@ function ImportStep({ |
| 1034 | 1054 | return; |
| 1035 | 1055 | } |
| 1036 | 1056 | setIsImporting(true); |
| 1037 | setImportError(""); | |
| 1057 | importWarningRef.current = ""; | |
| 1058 | importedThreadCountRef.current = 0; | |
| 1038 | 1059 | lastImportSelectionRef.current = selection.map((candidate) => candidate.key); |
| 1039 | 1060 | const importGeneration = importGenerationRef.current; |
| 1040 | 1061 | const importedProjects = importedProjectsRef.current; |
| ... | ... | @@ -1130,23 +1151,17 @@ function ImportStep({ |
| 1130 | 1151 | if (refreshEnvironments.has(scan.environmentId)) scan.refresh(); |
| 1131 | 1152 | } |
| 1132 | 1153 | setIsImporting(false); |
| 1154 | importedThreadCountRef.current = importedThreadCount; | |
| 1133 | 1155 | if (importedProjectsCount < selection.length) { |
| 1134 | 1156 | if (importedThreadCount > 0 && skippedThreadCount > 0) { |
| 1135 | setImportError( | |
| 1136 | `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, | |
| 1137 | ); | |
| 1157 | importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`; | |
| 1138 | 1158 | } else if (skippedThreadCount > 0) { |
| 1139 | setImportError( | |
| 1140 | `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, | |
| 1141 | ); | |
| 1159 | importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`; | |
| 1142 | 1160 | } else if (importedThreadCount > 0) { |
| 1143 | setImportError( | |
| 1144 | `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, | |
| 1145 | ); | |
| 1161 | importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`; | |
| 1146 | 1162 | } else { |
| 1147 | setImportError("Could not import thread history."); | |
| 1163 | importWarningRef.current = "Could not import thread history."; | |
| 1148 | 1164 | } |
| 1149 | return; | |
| 1150 | 1165 | } |
| 1151 | 1166 | finishAfterImport(); |
| 1152 | 1167 | }; |
| ... | ... | @@ -1253,14 +1268,9 @@ function ImportStep({ |
| 1253 | 1268 | })} |
| 1254 | 1269 | </div> |
| 1255 | 1270 | </ScrollArea> |
| 1256 | {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null} | |
| 1257 | 1271 | <div className="mt-6 flex flex-wrap items-center justify-end gap-3"> |
| 1258 | <Button | |
| 1259 | variant="ghost-muted" | |
| 1260 | disabled={isImporting} | |
| 1261 | onClick={importError ? finishAfterImport : () => void onDone()} | |
| 1262 | > | |
| 1263 | {importError ? "Continue without the rest" : "Do not import projects"} | |
| 1272 | <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}> | |
| 1273 | Do not import projects | |
| 1264 | 1274 | </Button> |
| 1265 | 1275 | <Button |
| 1266 | 1276 | autoFocus |
apps/web/src/routes/welcome.tsx+5-5| ... | ... | @@ -35,15 +35,15 @@ function WelcomeRouteView() { |
| 35 | 35 | {isWelcomeRoute && !dismissed ? ( |
| 36 | 36 | <WelcomeWizard |
| 37 | 37 | localAvailable={localAvailable} |
| 38 | onDone={(projectRef) => { | |
| 38 | onDone={async (projectRef) => { | |
| 39 | 39 | setDismissed(true); |
| 40 | 40 | if (projectRef !== undefined) { |
| 41 | void openNewThread(projectRef, { replace: true }).catch(() => { | |
| 42 | void navigate({ to: "/", replace: true }); | |
| 43 | }); | |
| 41 | await openNewThread(projectRef, { replace: true }).catch(() => | |
| 42 | navigate({ to: "/", replace: true }), | |
| 43 | ); | |
| 44 | 44 | return; |
| 45 | 45 | } |
| 46 | void navigate({ to: "/", replace: true }); | |
| 46 | await navigate({ to: "/", replace: true }); | |
| 47 | 47 | }} |
| 48 | 48 | /> |
| 49 | 49 | ) : null} |