authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-09-27 06:54:56-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-27 06:54:56-03:00
logde251fc2971a884cb5b1305ba4daf309dc8cccb0
tree52c240bfe326575ce4f81b70d285b6d27208388f
parenta727d1d97690c9bb12cee5760e91cfd1aa7c017d
signature Signed by PGP key B5690EEEBB952194

fix(web): continue onboarding after incomplete history imports (#13935)

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
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { EnvironmentId, ProjectId } from "@t3tools/contracts";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const 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}));
15vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));
16vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));
17vi.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}));
25vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => mocks.complete }));
26vi.mock("../../state/entities", () => ({
27 useProjects: () => mocks.projects,
28 readProjects: () => mocks.projects,
29}));
30vi.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});
41vi.mock("../../state/server", () => ({
42 serverEnvironment: {
43 providersValueAtom: () => [],
44 configValueAtom: () => null,
45 refreshProviders: "refresh",
46 },
47}));
48vi.mock("@effect/atom-react", () => ({ useAtomValue: (value: unknown) => value }));
49vi.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}));
72vi.mock("../../connection/onboarding", () => ({ connectPairing: vi.fn() }));
73vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
74vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
75vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
76vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));
77vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
78 CloudEnvironmentConnectRows: () => null,
79}));
80vi.mock("../ui/toast", () => ({
81 toastManager: { add: mocks.toast, close: vi.fn(), update: vi.fn() },
82}));
83
84import { WelcomeWizard } from "./WelcomeWizard";
85
86let root: Root;
87let container: HTMLDivElement;
88
89beforeEach(() => {
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});
115afterEach(async () => {
116 await act(async () => root.unmount());
117 container.remove();
118});
119
120async 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
128it("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
155it.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
189it("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({
9999}: {
100100 /** Whether this client is authenticated to the server serving the app. */
101101 readonly localAvailable: boolean;
102 readonly onDone: (projectRef?: ScopedProjectRef) => void;
102 readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;
103103}) {
104104 const completeOnboarding = useCompleteOnboarding();
105105 const [step, setStep] = useState<WizardStep>("connection");
......@@ -141,7 +141,7 @@ export function WelcomeWizard({
141141 };
142142 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
143143 const finish = useCallback(
144 (projectRef?: ScopedProjectRef) => {
144 (projectRef?: ScopedProjectRef, importWarning?: string, importedThreadCount = 0) => {
145145 if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
146146 if (completionErrorToastIdRef.current !== null) {
147147 toastManager.close(completionErrorToastIdRef.current);
......@@ -149,12 +149,25 @@ export function WelcomeWizard({
149149 }
150150
151151 const completion = completeOnboarding()
152 .then(() => {
152 .then(async () => {
153153 if (completionErrorToastIdRef.current !== null) {
154154 toastManager.close(completionErrorToastIdRef.current);
155155 completionErrorToastIdRef.current = null;
156156 }
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 }
158171 return true;
159172 })
160173 .catch(() => {
......@@ -952,14 +965,19 @@ function ImportStep({
952965 readonly scans: ReturnType<typeof useProjectScans>;
953966 readonly isImporting: boolean;
954967 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>;
956973}) {
957974 const { environments } = useEnvironments();
958975 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
959976 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
960977 const projects = useProjects();
961978 const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
962 const [importError, setImportError] = useState("");
979 const importWarningRef = useRef("");
980 const importedThreadCountRef = useRef(0);
963981 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
964982 // Keep project creation attempts separate from completed history imports so both can retry.
965983 const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
......@@ -988,9 +1006,11 @@ function ImportStep({
9881006 )
9891007 ) {
9901008 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 );
9941014 }
9951015 }, [landingProject, onDone, projects, setIsImporting]);
9961016
......@@ -1020,7 +1040,7 @@ function ImportStep({
10201040 importedProjectsRef.current,
10211041 );
10221042 if (projectRef === undefined) {
1023 void onDone();
1043 void onDone(undefined, importWarningRef.current, importedThreadCountRef.current);
10241044 return;
10251045 }
10261046 setIsImporting(true);
......@@ -1034,7 +1054,8 @@ function ImportStep({
10341054 return;
10351055 }
10361056 setIsImporting(true);
1037 setImportError("");
1057 importWarningRef.current = "";
1058 importedThreadCountRef.current = 0;
10381059 lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
10391060 const importGeneration = importGenerationRef.current;
10401061 const importedProjects = importedProjectsRef.current;
......@@ -1130,23 +1151,17 @@ function ImportStep({
11301151 if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
11311152 }
11321153 setIsImporting(false);
1154 importedThreadCountRef.current = importedThreadCount;
11331155 if (importedProjectsCount < selection.length) {
11341156 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.`;
11381158 } 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.`;
11421160 } 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.`;
11461162 } else {
1147 setImportError("Could not import thread history.");
1163 importWarningRef.current = "Could not import thread history.";
11481164 }
1149 return;
11501165 }
11511166 finishAfterImport();
11521167 };
......@@ -1253,14 +1268,9 @@ function ImportStep({
12531268 })}
12541269 </div>
12551270 </ScrollArea>
1256 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
12571271 <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
12641274 </Button>
12651275 <Button
12661276 autoFocus
apps/web/src/routes/welcome.tsx+5-5
......@@ -35,15 +35,15 @@ function WelcomeRouteView() {
3535 {isWelcomeRoute && !dismissed ? (
3636 <WelcomeWizard
3737 localAvailable={localAvailable}
38 onDone={(projectRef) => {
38 onDone={async (projectRef) => {
3939 setDismissed(true);
4040 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 );
4444 return;
4545 }
46 void navigate({ to: "/", replace: true });
46 await navigate({ to: "/", replace: true });
4747 }}
4848 />
4949 ) : null}