diff --git a/packages/app/src/onboarding/internal/model.test.ts b/packages/app/src/onboarding/internal/model.test.ts index 445c165892..548311274e 100644 --- a/packages/app/src/onboarding/internal/model.test.ts +++ b/packages/app/src/onboarding/internal/model.test.ts @@ -93,8 +93,8 @@ describe("onboarding model", () => { it("keeps the modal open when the host gains its first project during setup", async () => { const model = await openModel(); model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); - model.next(); - model.next(); + model.next("appearance"); + model.next("providers"); model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); @@ -117,10 +117,10 @@ describe("onboarding model", () => { model.back(); expect(model.getState()).toMatchObject({ stepIndex: 0 }); - model.next(); - model.next(); - model.next(); - model.next(); + model.next("appearance"); + model.next("providers"); + model.next("projects"); + model.next("orchestration"); expect(model.getState()).toMatchObject({ stepIndex: 3 }); model.goTo("providers"); expect(model.getState()).toMatchObject({ stepIndex: 1 }); @@ -128,6 +128,23 @@ describe("onboarding model", () => { expect(model.getState()).toMatchObject({ stepIndex: 0 }); }); + it("ignores a late advance from a step the user already left", async () => { + const model = await openModel(new MemoryCompletionStorage(true)); + model.reopen("local", { includePhoneStep: true }); + model.goTo("projects"); + + // Projects are still being added when the user picks Appearance in the step rail. + model.goTo("appearance"); + model.next("projects"); + expect(model.getState()).toMatchObject({ stepIndex: 0 }); + + // Or closes setup and opens it again before the add finishes. + await model.complete(); + model.reopen("local", { includePhoneStep: true }); + model.next("projects"); + expect(model.getState()).toMatchObject({ status: "open", stepIndex: 0 }); + }); + it("records completion when setup closes, so it does not open again", async () => { const storage = new MemoryCompletionStorage(); const model = await openModel(storage); @@ -160,7 +177,7 @@ describe("onboarding model", () => { }); model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); - model.next(); + model.next("appearance"); await model.complete(); expect(seen).toEqual(["open:0", "open:1", "hidden"]); diff --git a/packages/app/src/onboarding/internal/model.ts b/packages/app/src/onboarding/internal/model.ts index 4b2fb9584d..66f91953fa 100644 --- a/packages/app/src/onboarding/internal/model.ts +++ b/packages/app/src/onboarding/internal/model.ts @@ -43,7 +43,8 @@ export interface OnboardingModel { subscribe(listener: () => void): () => void; applySetupHost(host: OnboardingSetupHost | null, options: OnboardingStepOptions): void; reopen(serverId: string, options: OnboardingStepOptions): void; - next(): void; + /** Leaves `from` for the following step. A no-op once the user is on another step or closed setup. */ + next(from: OnboardingStepId): void; back(): void; goTo(step: OnboardingStepId): void; /** Closes setup, whether the user finished or skipped it, and remembers that on this client. */ @@ -135,8 +136,10 @@ export function createOnboardingModel(input: { reopen(serverId, options) { setState(openState(serverId, options)); }, - next() { - if (state.status === "open") moveTo(state.stepIndex + 1); + next(from) { + if (state.status === "open" && state.steps[state.stepIndex] === from) { + moveTo(state.stepIndex + 1); + } }, back() { if (state.status === "open") moveTo(state.stepIndex - 1); diff --git a/packages/app/src/onboarding/internal/onboarding-modal.tsx b/packages/app/src/onboarding/internal/onboarding-modal.tsx index 5f713e14dd..793235e24e 100644 --- a/packages/app/src/onboarding/internal/onboarding-modal.tsx +++ b/packages/app/src/onboarding/internal/onboarding-modal.tsx @@ -34,10 +34,10 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat void model.complete(); }, [model]); const handleBack = useCallback(() => model.back(), [model]); - const handleNext = useCallback(() => model.next(), [model]); + const handleNext = useCallback(() => model.next(step), [model, step]); const handleAddSuggestions = useCallback(() => { void (async () => { - if (await addChecked()) model.next(); + if (await addChecked()) model.next("projects"); })(); }, [addChecked, model]); const primary = usePrimaryAction({ diff --git a/packages/app/src/project-suggestions/internal/project-suggestions.tsx b/packages/app/src/project-suggestions/internal/project-suggestions.tsx index 4766b23dd9..18cbcb03d0 100644 --- a/packages/app/src/project-suggestions/internal/project-suggestions.tsx +++ b/packages/app/src/project-suggestions/internal/project-suggestions.tsx @@ -51,11 +51,12 @@ export function ProjectSuggestionList({ /> ))} - ) : ( + ) : null} + {list.rows.length === 0 && list.reading.length === 0 ? ( {t("onboarding.setup.steps.projects.suggestions.empty")} - )} + ) : null} {list.reading.length > 0 ? : null} {list.failed.map((provider) => ( diff --git a/packages/app/src/project-suggestions/internal/suggestions.test.ts b/packages/app/src/project-suggestions/internal/suggestions.test.ts index f4b1ae9bd5..beb6928356 100644 --- a/packages/app/src/project-suggestions/internal/suggestions.test.ts +++ b/packages/app/src/project-suggestions/internal/suggestions.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { addSuggestedProjects, + isAwaitingFirstAnswer, isSuggestionChecked, mergeProviderSuggestions, type ProjectSuggestion, + type ProviderSuggestions, } from "./suggestions"; const NOW = new Date("2026-10-31T12:00:00.000Z"); @@ -128,3 +130,20 @@ describe("addSuggestedProjects", () => { expect(failures).toEqual(new Map([["/dev/app", "Host disconnected"]])); }); }); + +describe("isAwaitingFirstAnswer", () => { + it("stops waiting once any provider answers, so a failure shows while others still read", () => { + const reading: ProviderSuggestions = { provider: "codex", label: "Codex", status: "loading" }; + const failed: ProviderSuggestions = { + provider: "opencode", + label: "OpenCode", + status: "failed", + message: "Timed out", + suggestions: [], + }; + + expect(isAwaitingFirstAnswer([reading, { ...reading, provider: "pi" }])).toBe(true); + expect(isAwaitingFirstAnswer([reading, failed])).toBe(false); + expect(isAwaitingFirstAnswer([])).toBe(false); + }); +}); diff --git a/packages/app/src/project-suggestions/internal/suggestions.ts b/packages/app/src/project-suggestions/internal/suggestions.ts index aa7f940f3f..fd13a6c7ab 100644 --- a/packages/app/src/project-suggestions/internal/suggestions.ts +++ b/packages/app/src/project-suggestions/internal/suggestions.ts @@ -48,6 +48,14 @@ function combineSuggestions(a: ProjectSuggestion, b: ProjectSuggestion): Project }; } +/** + * True until the first provider answers. After that the list shows what it has, including a failed + * provider's Retry, while the slower providers keep reading. + */ +export function isAwaitingFirstAnswer(providers: readonly ProviderSuggestions[]): boolean { + return providers.length > 0 && providers.every((provider) => provider.status === "loading"); +} + /** Folders the user worked in often and recently start checked. */ export function isPreselected(suggestion: ProjectSuggestion, now: Date): boolean { return ( diff --git a/packages/app/src/project-suggestions/internal/use-project-suggestions.ts b/packages/app/src/project-suggestions/internal/use-project-suggestions.ts index 0fb29a48f4..b7b00192df 100644 --- a/packages/app/src/project-suggestions/internal/use-project-suggestions.ts +++ b/packages/app/src/project-suggestions/internal/use-project-suggestions.ts @@ -4,6 +4,7 @@ import { useOpenProject } from "@/hooks/use-open-project"; import { useProjectRootPaths } from "@/stores/session-store-hooks"; import { addSuggestedProjects, + isAwaitingFirstAnswer, isSuggestionChecked, mergeProviderSuggestions, type ProjectSuggestion, @@ -20,7 +21,7 @@ export interface SuggestionRow { export type SuggestionList = | { status: "unsupported" } - /** Nothing to show yet: the provider list or every provider's first answer is pending. */ + /** Nothing to show yet: the provider list or the first provider's answer is pending. */ | { status: "loading" } | { status: "ready"; @@ -86,8 +87,7 @@ export function useProjectSuggestions(serverId: string): ProjectSuggestionsContr const list = useMemo((): SuggestionList => { if (state.status !== "ready") return state; - const reading = state.providers.filter((provider) => provider.status === "loading"); - if (suggestions.length === 0 && reading.length > 0) return { status: "loading" }; + if (isAwaitingFirstAnswer(state.providers)) return { status: "loading" }; return { status: "ready", rows: suggestions.map((suggestion) => ({ @@ -98,7 +98,7 @@ export function useProjectSuggestions(serverId: string): ProjectSuggestionsContr providerLabels: new Map( state.providers.map((provider) => [provider.provider, provider.label]), ), - reading, + reading: state.providers.filter((provider) => provider.status === "loading"), failed: state.providers.filter((provider) => provider.status === "failed"), toggle, retry: state.retry, diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs index bb19c947ef..a98a158511 100644 --- a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -72,11 +72,6 @@ export async function verifyAttachedDaemonControls({ repo, root, env, home, port page = await desktop.firstWindow(); page.on("pageerror", (error) => console.log("Renderer error:", error.message)); await page.route(/:(6767|6768)\b/, (route) => route.abort()); - // The attached daemon has no projects, so first-run setup would open over Settings. - await page.evaluate(() => - localStorage.setItem("@paseo:onboarding", JSON.stringify({ completedAt: "1970-01-01" })), - ); - await page.reload(); await page.getByRole("button", { name: "Settings", exact: true }).click({ timeout: 90_000 }); await page.getByRole("button", { name: "Enable built-in daemon", exact: true }).click(); await expect(page.getByText("Attached to an existing daemon", { exact: true })).toBeVisible();