fix(onboarding): keep late project adds on their step and show Retry early

- A finished add only advances setup while the Projects step is still open.
- A failed provider shows its Retry as soon as any provider answers.
- The desktop lifecycle check no longer reloads the window: setup never opens
  there because no host is connected.
This commit is contained in:
Mohamed Boudra
2026-10-31 10:57:11 +01:00
parent 1284ff355f
commit 0390379e60
8 changed files with 66 additions and 23 deletions
@@ -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"]);
@@ -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);
@@ -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({
@@ -51,11 +51,12 @@ export function ProjectSuggestionList({
/>
))}
</View>
) : (
) : null}
{list.rows.length === 0 && list.reading.length === 0 ? (
<Text style={styles.statusText}>
{t("onboarding.setup.steps.projects.suggestions.empty")}
</Text>
)}
) : null}
{list.reading.length > 0 ? <StillReading providers={list.reading} /> : null}
{list.failed.map((provider) => (
<ProviderFailure key={provider.provider} provider={provider} onRetry={list.retry} />
@@ -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);
});
});
@@ -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 (
@@ -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,
@@ -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();