Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions apps/web/src/components/onboarding/WelcomeWizard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({
importThreads: vi.fn(),
createProject: vi.fn(),
complete: vi.fn(),
refresh: vi.fn(),
toast: vi.fn(),
projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>,
}));
vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));
vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));
vi.mock("../../state/use-atom-command", () => ({
useAtomCommand: (command: string) =>
command === "import"
? mocks.importThreads
: command === "create"
? mocks.createProject
: mocks.refresh,
}));
vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => mocks.complete }));
vi.mock("../../state/entities", () => ({
useProjects: () => mocks.projects,
readProjects: () => mocks.projects,
}));
vi.mock("../../state/environments", () => {
const environment = {
environmentId: "test-env",
label: "Computer",
connection: { phase: "connected" },
};
return {
useEnvironments: () => ({ environments: [environment] }),
usePrimaryEnvironment: () => environment,
};
});
vi.mock("../../state/server", () => ({
serverEnvironment: {
providersValueAtom: () => [],
configValueAtom: () => null,
refreshProviders: "refresh",
},
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: (value: unknown) => value }));
vi.mock("../../onboarding/useProjectScans", () => ({
useProjectScans: () => [
{
environmentId: "test-env",
isPending: false,
error: null,
refresh: mocks.refresh,
data: {
truncated: false,
candidates: [
{
path: "/project",
title: "project",
projectId: "test-project",
threadCount: 29,
lastActiveAt: new Date().toISOString(),
sources: ["codex"],
},
],
},
},
],
}));
vi.mock("../../connection/onboarding", () => ({ connectPairing: vi.fn() }));
vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));
vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
CloudEnvironmentConnectRows: () => null,
}));
vi.mock("../ui/toast", () => ({
toastManager: { add: mocks.toast, close: vi.fn(), update: vi.fn() },
}));

import { WelcomeWizard } from "./WelcomeWizard";

let root: Root;
let container: HTMLDivElement;

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
Object.defineProperty(Element.prototype, "getAnimations", {
configurable: true,
value: () => [],
});
mocks.projects = [{ id: "test-project", environmentId: "test-env", workspaceRoot: "/project" }];
mocks.complete.mockResolvedValue(undefined);
mocks.refresh.mockResolvedValue(undefined);
mocks.importThreads.mockResolvedValue({
_tag: "Success",
value: { importedCount: 28, skippedCount: 1 },
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});

async function click(label: string) {
const button = [...document.querySelectorAll("button")].find(
(element) => element.textContent?.trim() === label,
);
expect(button, `button ${label}`).toBeDefined();
await act(async () => button!.click());
}

it("enters the workspace after a partial import and warns after navigation finishes", async () => {
let finishNavigation = () => {};
const navigation = new Promise<void>((resolve) => {
finishNavigation = resolve;
});
const onDone = vi.fn(() => navigation);
await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />));
await click("Continue");
await click("Continue");
await click("Import 1 project");
expect(onDone).toHaveBeenCalledWith({
environmentId: EnvironmentId.make("test-env"),
projectId: ProjectId.make("test-project"),
});
expect(mocks.toast).not.toHaveBeenCalled();
await act(async () => finishNavigation());
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({
type: "warning",
description: "Imported 28 threads. 1 thread could not be imported.",
}),
);
expect(mocks.toast.mock.invocationCallOrder[0]).toBeGreaterThan(
onDone.mock.invocationCallOrder[0]!,
);
});

it.each([
[0, 0, null],
[29, 0, null],
[1, 0, null],
[0, 1, "1 thread could not be imported."],
[0, 2, "2 threads could not be imported."],
] as const)(
"finishes setup with %i imported and %i skipped threads",
async (importedCount, skippedCount, warning) => {
mocks.importThreads.mockResolvedValue({
_tag: "Success",
value: { importedCount, skippedCount },
});
const onDone = vi.fn();
await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />));
await click("Continue");
await click("Continue");
await click("Import 1 project");
expect(onDone).toHaveBeenCalledOnce();
if (warning === null && importedCount > 0) {
expect(mocks.toast).toHaveBeenCalledWith({
type: "success",
title: `Imported ${importedCount} ${importedCount === 1 ? "thread" : "threads"}`,
});
} else if (warning === null) {
expect(mocks.toast).not.toHaveBeenCalled();
} else {
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({ type: "warning", description: warning }),
);
}
},
);

it("keeps setup open when saving completion fails and preserves the import warning on retry", async () => {
mocks.complete.mockRejectedValueOnce(new Error("settings unavailable"));
const onDone = vi.fn();
await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />));
await click("Continue");
await click("Continue");
await click("Import 1 project");
expect(onDone).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({ type: "error", title: "Could not finish setup" }),
);
await click("Do not import projects");
expect(onDone).toHaveBeenCalledOnce();
expect(mocks.importThreads).toHaveBeenCalledOnce();
expect(mocks.toast).toHaveBeenLastCalledWith(
expect.objectContaining({
type: "warning",
description: "Imported 28 threads. 1 thread could not be imported.",
}),
);
});
68 changes: 39 additions & 29 deletions apps/web/src/components/onboarding/WelcomeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function WelcomeWizard({
}: {
/** Whether this client is authenticated to the server serving the app. */
readonly localAvailable: boolean;
readonly onDone: (projectRef?: ScopedProjectRef) => void;
readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;
}) {
const completeOnboarding = useCompleteOnboarding();
const [step, setStep] = useState<WizardStep>("connection");
Expand Down Expand Up @@ -141,20 +141,33 @@ export function WelcomeWizard({
};
const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
const finish = useCallback(
(projectRef?: ScopedProjectRef) => {
(projectRef?: ScopedProjectRef, importWarning?: string, importedThreadCount = 0) => {
if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
if (completionErrorToastIdRef.current !== null) {
toastManager.close(completionErrorToastIdRef.current);
completionErrorToastIdRef.current = null;
}

const completion = completeOnboarding()
.then(() => {
.then(async () => {
if (completionErrorToastIdRef.current !== null) {
toastManager.close(completionErrorToastIdRef.current);
completionErrorToastIdRef.current = null;
}
onDone(projectRef);
await onDone(projectRef);
if (importWarning) {
toastManager.add({
type: "warning",
title: "Some history was not imported",
description: importWarning,
timeout: 0,
});
} else if (importedThreadCount > 0) {
toastManager.add({
type: "success",
title: `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}`,
});
}
return true;
})
.catch(() => {
Expand Down Expand Up @@ -952,14 +965,19 @@ function ImportStep({
readonly scans: ReturnType<typeof useProjectScans>;
readonly isImporting: boolean;
readonly setIsImporting: (value: boolean) => void;
readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>;
readonly onDone: (
projectRef?: ScopedProjectRef,
importWarning?: string,
importedThreadCount?: number,
) => Promise<boolean>;
}) {
const { environments } = useEnvironments();
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
const projects = useProjects();
const [selectedPaths, setSelectedPaths] = useState<ReadonlySet<string> | null>(null);
const [importError, setImportError] = useState("");
const importWarningRef = useRef("");
const importedThreadCountRef = useRef(0);
const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
// Keep project creation attempts separate from completed history imports so both can retry.
const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
Expand Down Expand Up @@ -988,9 +1006,11 @@ function ImportStep({
)
) {
setLandingProject(null);
void onDone(landingProject).then((completed) => {
if (!completed) setIsImporting(false);
});
void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then(
(completed) => {
if (!completed) setIsImporting(false);
},
);
}
}, [landingProject, onDone, projects, setIsImporting]);

Expand Down Expand Up @@ -1020,7 +1040,7 @@ function ImportStep({
importedProjectsRef.current,
);
if (projectRef === undefined) {
void onDone();
void onDone(undefined, importWarningRef.current, importedThreadCountRef.current);
return;
}
setIsImporting(true);
Expand All @@ -1034,7 +1054,8 @@ function ImportStep({
return;
}
setIsImporting(true);
setImportError("");
importWarningRef.current = "";
importedThreadCountRef.current = 0;
lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
const importGeneration = importGenerationRef.current;
const importedProjects = importedProjectsRef.current;
Expand Down Expand Up @@ -1130,23 +1151,17 @@ function ImportStep({
if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
}
setIsImporting(false);
importedThreadCountRef.current = importedThreadCount;
if (importedProjectsCount < selection.length) {
if (importedThreadCount > 0 && skippedThreadCount > 0) {
setImportError(
`Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`,
);
importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`;
} else if (skippedThreadCount > 0) {
setImportError(
`${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`,
);
importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`;
} else if (importedThreadCount > 0) {
setImportError(
`Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`,
);
importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`;
} else {
setImportError("Could not import thread history.");
importWarningRef.current = "Could not import thread history.";
}
return;
}
finishAfterImport();
};
Expand Down Expand Up @@ -1253,14 +1268,9 @@ function ImportStep({
})}
</div>
</ScrollArea>
{importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-3">
<Button
variant="ghost-muted"
disabled={isImporting}
onClick={importError ? finishAfterImport : () => void onDone()}
>
{importError ? "Continue without the rest" : "Do not import projects"}
<Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}>
Do not import projects
</Button>
<Button
autoFocus
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/routes/welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ function WelcomeRouteView() {
{isWelcomeRoute && !dismissed ? (
<WelcomeWizard
localAvailable={localAvailable}
onDone={(projectRef) => {
onDone={async (projectRef) => {
setDismissed(true);
if (projectRef !== undefined) {
void openNewThread(projectRef, { replace: true }).catch(() => {
void navigate({ to: "/", replace: true });
});
await openNewThread(projectRef, { replace: true }).catch(() =>
navigate({ to: "/", replace: true }),
);
return;
}
void navigate({ to: "/", replace: true });
await navigate({ to: "/", replace: true });
}}
/>
) : null}
Expand Down
11 changes: 0 additions & 11 deletions infra/relay/src/dbConfig.test.ts

This file was deleted.

Loading