mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user