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();