fix(app): ignore a late Finish once the user left the last setup step

Finish called complete(), which ignored the step visit, so pressing Back
while Orchestration applied its choices still closed setup and recorded it
as done. next(visit) now finishes setup from the last step, so a late
Finish is dropped the same way a late Continue already was.
This commit is contained in:
Mohamed Boudra
2026-11-02 17:52:33 +01:00
parent 8a9b62e938
commit 23d928ad10
3 changed files with 35 additions and 15 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
createOnboardingModel,
type OnboardingCompletionStorage,
@@ -134,7 +134,6 @@ describe("onboarding model", () => {
advance(model);
advance(model);
advance(model);
advance(model);
expect(model.getState()).toMatchObject({ stepIndex: 3 });
model.goTo("providers");
expect(model.getState()).toMatchObject({ stepIndex: 1 });
@@ -162,6 +161,25 @@ describe("onboarding model", () => {
expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 });
});
it("moving on from the last step finishes setup, unless the user left that step first", async () => {
const storage = new MemoryCompletionStorage();
const model = await openModel(storage);
model.applySetupHost(FRESH_HOST, { includePhoneStep: false });
model.goTo("orchestration");
// Finish is applying choices when the user goes back to Projects.
const finishingFrom = currentVisit(model);
model.back();
model.next(finishingFrom);
expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 });
expect(storage.completed).toBe(false);
advance(model);
advance(model);
await vi.waitFor(() => expect(storage.completed).toBe(true));
expect(model.getState()).toEqual({ status: "hidden" });
});
it("records completion when setup closes, so it does not open again", async () => {
const storage = new MemoryCompletionStorage();
const model = await openModel(storage);
+13 -8
View File
@@ -46,8 +46,9 @@ export interface OnboardingModel {
applySetupHost(host: OnboardingSetupHost | null, options: OnboardingStepOptions): void;
reopen(serverId: string, options: OnboardingStepOptions): void;
/**
* Moves on from the step visit the caller saw. A no-op once that visit ended, so an action that
* finishes late never moves a step the user left or a setup they closed and reopened.
* Moves on from the step visit the caller saw; from the last step, that finishes setup. A no-op
* once that visit ended, so an action that finishes late never moves a step the user left or
* closes a setup they went back in, or closed and reopened.
*/
next(visit: number): void;
back(): void;
@@ -129,6 +130,12 @@ export function createOnboardingModel(input: {
const ready = loadCompletion();
async function complete() {
completion = "completed";
setState({ status: "hidden" });
await input.storage.markCompleted();
}
return {
ready,
getState: () => state,
@@ -146,7 +153,9 @@ export function createOnboardingModel(input: {
setState(openState(serverId, options));
},
next(visit) {
if (state.status === "open" && state.visit === visit) moveTo(state.stepIndex + 1);
if (state.status !== "open" || state.visit !== visit) return;
if (state.stepIndex === state.steps.length - 1) void complete();
else moveTo(state.stepIndex + 1);
},
back() {
if (state.status === "open") moveTo(state.stepIndex - 1);
@@ -156,10 +165,6 @@ export function createOnboardingModel(input: {
const stepIndex = state.steps.indexOf(step);
if (stepIndex >= 0) moveTo(stepIndex);
},
async complete() {
completion = "completed";
setState({ status: "hidden" });
await input.storage.markCompleted();
},
complete,
};
}
@@ -42,11 +42,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
}, [model]);
const handleBack = useCallback(() => model.back(), [model]);
const { visit } = state;
// Moving on from the last step finishes setup; any step's own work runs before that.
const handleAdvance = useCallback(() => {
if (isLast) void model.complete();
else model.next(visit);
}, [isLast, model, visit]);
// A step's own work runs before it moves on; from the last step, moving on finishes setup.
const handleAdvance = useCallback(() => model.next(visit), [model, visit]);
const handleAddSuggestions = useCallback(() => {
void (async () => {
if (await addChecked()) handleAdvance();