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({...@@ -99,7 +99,7 @@ export function WelcomeWizard({
99}: {99}: {
100 /** Whether this client is authenticated to the server serving the app. */100 /** Whether this client is authenticated to the server serving the app. */
101 readonly localAvailable: boolean;101 readonly localAvailable: boolean;
102 readonly onDone: (projectRef?: ScopedProjectRef) => void;102 readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;
103}) {103}) {
104 const completeOnboarding = useCompleteOnboarding();104 const completeOnboarding = useCompleteOnboarding();
105 const [step, setStep] = useState<WizardStep>("connection");105 const [step, setStep] = useState<WizardStep>("connection");
...@@ -141,7 +141,7 @@ export function WelcomeWizard({...@@ -141,7 +141,7 @@ export function WelcomeWizard({
141 };141 };
142 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;142 const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
143 const finish = useCallback(143 const finish = useCallback(
144 (projectRef?: ScopedProjectRef) => {144 (projectRef?: ScopedProjectRef, importWarning?: string, importedThreadCount = 0) => {
145 if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;145 if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
146 if (completionErrorToastIdRef.current !== null) {146 if (completionErrorToastIdRef.current !== null) {
147 toastManager.close(completionErrorToastIdRef.current);147 toastManager.close(completionErrorToastIdRef.current);
...@@ -149,12 +149,25 @@ export function WelcomeWizard({...@@ -149,12 +149,25 @@ export function WelcomeWizard({
149 }149 }
150150
151 const completion = completeOnboarding()151 const completion = completeOnboarding()
152 .then(() => {152 .then(async () => {
153 if (completionErrorToastIdRef.current !== null) {153 if (completionErrorToastIdRef.current !== null) {
154 toastManager.close(completionErrorToastIdRef.current);154 toastManager.close(completionErrorToastIdRef.current);
155 completionErrorToastIdRef.current = null;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 return true;171 return true;
159 })172 })
160 .catch(() => {173 .catch(() => {
...@@ -952,14 +965,19 @@ function ImportStep({...@@ -952,14 +965,19 @@ function ImportStep({
952 readonly scans: ReturnType<typeof useProjectScans>;965 readonly scans: ReturnType<typeof useProjectScans>;
953 readonly isImporting: boolean;966 readonly isImporting: boolean;
954 readonly setIsImporting: (value: boolean) => void;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 const { environments } = useEnvironments();974 const { environments } = useEnvironments();
958 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });975 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
959 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });976 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
960 const projects = useProjects();977 const projects = useProjects();
961 const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);978 const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
962 const [importError, setImportError] = useState("");979 const importWarningRef = useRef("");
980 const importedThreadCountRef = useRef(0);
963 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);981 const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
964 // Keep project creation attempts separate from completed history imports so both can retry.982 // Keep project creation attempts separate from completed history imports so both can retry.
965 const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());983 const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
...@@ -988,9 +1006,11 @@ function ImportStep({...@@ -988,9 +1006,11 @@ function ImportStep({
988 )1006 )
989 ) {1007 ) {
990 setLandingProject(null);1008 setLandingProject(null);
991 void onDone(landingProject).then((completed) => {1009 void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then(
992 if (!completed) setIsImporting(false);1010 (completed) => {
993 });1011 if (!completed) setIsImporting(false);
1012 },
1013 );
994 }1014 }
995 }, [landingProject, onDone, projects, setIsImporting]);1015 }, [landingProject, onDone, projects, setIsImporting]);
9961016
...@@ -1020,7 +1040,7 @@ function ImportStep({...@@ -1020,7 +1040,7 @@ function ImportStep({
1020 importedProjectsRef.current,1040 importedProjectsRef.current,
1021 );1041 );
1022 if (projectRef === undefined) {1042 if (projectRef === undefined) {
1023 void onDone();1043 void onDone(undefined, importWarningRef.current, importedThreadCountRef.current);
1024 return;1044 return;
1025 }1045 }
1026 setIsImporting(true);1046 setIsImporting(true);
...@@ -1034,7 +1054,8 @@ function ImportStep({...@@ -1034,7 +1054,8 @@ function ImportStep({
1034 return;1054 return;
1035 }1055 }
1036 setIsImporting(true);1056 setIsImporting(true);
1037 setImportError("");1057 importWarningRef.current = "";
1058 importedThreadCountRef.current = 0;
1038 lastImportSelectionRef.current = selection.map((candidate) => candidate.key);1059 lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
1039 const importGeneration = importGenerationRef.current;1060 const importGeneration = importGenerationRef.current;
1040 const importedProjects = importedProjectsRef.current;1061 const importedProjects = importedProjectsRef.current;
...@@ -1130,23 +1151,17 @@ function ImportStep({...@@ -1130,23 +1151,17 @@ function ImportStep({
1130 if (refreshEnvironments.has(scan.environmentId)) scan.refresh();1151 if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
1131 }1152 }
1132 setIsImporting(false);1153 setIsImporting(false);
1154 importedThreadCountRef.current = importedThreadCount;
1133 if (importedProjectsCount < selection.length) {1155 if (importedProjectsCount < selection.length) {
1134 if (importedThreadCount > 0 && skippedThreadCount > 0) {1156 if (importedThreadCount > 0 && skippedThreadCount > 0) {
1135 setImportError(1157 importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`;
1136 `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`,
1137 );
1138 } else if (skippedThreadCount > 0) {1158 } else if (skippedThreadCount > 0) {
1139 setImportError(1159 importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`;
1140 `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`,
1141 );
1142 } else if (importedThreadCount > 0) {1160 } else if (importedThreadCount > 0) {
1143 setImportError(1161 importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`;
1144 `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`,
1145 );
1146 } else {1162 } else {
1147 setImportError("Could not import thread history.");1163 importWarningRef.current = "Could not import thread history.";
1148 }1164 }
1149 return;
1150 }1165 }
1151 finishAfterImport();1166 finishAfterImport();
1152 };1167 };
...@@ -1253,14 +1268,9 @@ function ImportStep({...@@ -1253,14 +1268,9 @@ function ImportStep({
1253 })}1268 })}
1254 </div>1269 </div>
1255 </ScrollArea>1270 </ScrollArea>
1256 {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
1257 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">1271 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">
1258 <Button1272 <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}>
1259 variant="ghost-muted"1273 Do not import projects
1260 disabled={isImporting}
1261 onClick={importError ? finishAfterImport : () => void onDone()}
1262 >
1263 {importError ? "Continue without the rest" : "Do not import projects"}
1264 </Button>1274 </Button>
1265 <Button1275 <Button
1266 autoFocus1276 autoFocus
apps/web/src/routes/welcome.tsx+5-5
...@@ -35,15 +35,15 @@ function WelcomeRouteView() {...@@ -35,15 +35,15 @@ function WelcomeRouteView() {
35 {isWelcomeRoute && !dismissed ? (35 {isWelcomeRoute && !dismissed ? (
36 <WelcomeWizard36 <WelcomeWizard
37 localAvailable={localAvailable}37 localAvailable={localAvailable}
38 onDone={(projectRef) => {38 onDone={async (projectRef) => {
39 setDismissed(true);39 setDismissed(true);
40 if (projectRef !== undefined) {40 if (projectRef !== undefined) {
41 void openNewThread(projectRef, { replace: true }).catch(() => {41 await openNewThread(projectRef, { replace: true }).catch(() =>
42 void navigate({ to: "/", replace: true });42 navigate({ to: "/", replace: true }),
43 });43 );
44 return;44 return;
45 }45 }
46 void navigate({ to: "/", replace: true });46 await navigate({ to: "/", replace: true });
47 }}47 }}
48 />48 />
49 ) : null}49 ) : null}