diff --git a/memhub/apps/web/src/OnboardingFlow.test.tsx b/memhub/apps/web/src/OnboardingFlow.test.tsx
index 6733dab..660fb70 100644
--- a/memhub/apps/web/src/OnboardingFlow.test.tsx
+++ b/memhub/apps/web/src/OnboardingFlow.test.tsx
@@ -100,6 +100,18 @@ vi.mock("./OnboardingModelController", () => ({
>
模型启用回执已确认
+
@@ -445,6 +457,155 @@ describe("OnboardingFlow S1.3", () => {
).toBeTruthy();
});
+ it("accepts Lite once while the import submission is still in flight", async () => {
+ const user = userEvent.setup();
+ let resolveImport!: (
+ value: Awaited<
+ ReturnType
+ >,
+ ) => void;
+ const controller = imports({
+ connectForOnboarding: vi.fn(
+ () =>
+ new Promise<
+ Awaited>
+ >((resolve) => {
+ resolveImport = resolve;
+ }),
+ ),
+ });
+ const finish = vi.fn();
+ setMemoryMode.mockResolvedValueOnce({ mode: "lite" });
+ window.oneMemory = {} as NonNullable;
+ render(
+ ,
+ );
+
+ await choose(user);
+ await approve(user);
+ await user.click(screen.getByRole("button", { name: START }));
+ await user.click(
+ await screen.findByRole("button", { name: "选择记忆模式" }),
+ );
+ await user.click(screen.getByRole("radio", { name: /lite/i }));
+ const enter = screen.getByRole("button", { name: "使用 Lite 进入" });
+ expect(enter).toHaveProperty("disabled", false);
+
+ await user.click(enter);
+ await user.click(enter);
+ await waitFor(() =>
+ expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
+ );
+ expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("lite");
+ expect(completeOnboarding).toHaveBeenCalledOnce();
+
+ await act(async () =>
+ resolveImport({
+ accepted_source_ids: ["claude"],
+ failed_sources: {},
+ jobs: [],
+ }),
+ );
+ expect(finish).toHaveBeenCalledOnce();
+ expect(completeOnboarding).toHaveBeenCalledOnce();
+ });
+
+ it("shows distinct saving and completion feedback after one Lite click", async () => {
+ const user = userEvent.setup();
+ let resolveMode!: (value: { mode: "lite" }) => void;
+ let resolveCompletion!: (value: OnboardingState) => void;
+ setMemoryMode.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveMode = resolve;
+ }),
+ );
+ completeOnboarding.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveCompletion = resolve;
+ }),
+ );
+ const finish = vi.fn();
+ render(
+ ,
+ );
+
+ await openApps();
+ await user.click(screen.getByRole("button", { name: SKIP }));
+ await user.click(screen.getByRole("radio", { name: /lite/i }));
+ await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
+ expect(
+ screen.getByRole("button", { name: "正在保存模式…" }),
+ ).toHaveProperty("disabled", true);
+
+ await act(async () => resolveMode({ mode: "lite" }));
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: "正在完成首次设置…" }),
+ ).toHaveProperty("disabled", true),
+ );
+ expect(finish).not.toHaveBeenCalled();
+
+ await act(async () =>
+ resolveCompletion({
+ ...onboarding,
+ status: "completed",
+ }),
+ );
+ await waitFor(() =>
+ expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
+ );
+ });
+
+ it("fences duplicate Pro activation receipts while completion is pending", async () => {
+ const user = userEvent.setup();
+ let resolveCompletion!: (value: OnboardingState) => void;
+ completeOnboarding.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveCompletion = resolve;
+ }),
+ );
+ const finish = vi.fn();
+ render(
+ ,
+ );
+
+ await openApps();
+ await user.click(screen.getByRole("button", { name: SKIP }));
+ await user.click(screen.getByRole("radio", { name: /pro/i }));
+ await user.click(screen.getByRole("button", { name: "继续配置模型" }));
+ const receipt = screen.getByRole("button", {
+ name: "模型启用回执已确认",
+ });
+ fireEvent.click(receipt);
+ fireEvent.click(receipt);
+
+ await waitFor(() => expect(completeOnboarding).toHaveBeenCalledOnce());
+ expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("pro");
+ expect(finish).not.toHaveBeenCalled();
+
+ await act(async () =>
+ resolveCompletion({
+ ...onboarding,
+ status: "completed",
+ }),
+ );
+ await waitFor(() =>
+ expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
+ );
+ });
+
it("D47 entering the same-page model step does not finish setup or grant processing", async () => {
const finish = vi.fn();
render(
@@ -862,6 +1023,7 @@ describe("OnboardingFlow S1.3", () => {
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
+ let resolveLite!: (value: { mode: "lite" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
@@ -869,7 +1031,12 @@ describe("OnboardingFlow S1.3", () => {
resolvePro = resolve;
}),
)
- .mockResolvedValueOnce({ mode: "lite" });
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveLite = resolve;
+ }),
+ );
const user = userEvent.setup();
const finish = vi.fn();
render(
@@ -900,10 +1067,118 @@ describe("OnboardingFlow S1.3", () => {
"pro",
"lite",
]);
+ expect(
+ screen.getByRole("button", { name: "正在保存模式…" }),
+ ).toHaveProperty("disabled", true);
+ await act(async () => resolveLite({ mode: "lite" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(completeOnboarding).toHaveBeenCalledOnce();
});
+ it("accepts a new Pro generation while an older Pro write is pending", async () => {
+ let resolveFirst!: (value: { mode: "pro" }) => void;
+ let resolveSecond!: (value: { mode: "pro" }) => void;
+ setMemoryMode
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ const finish = vi.fn();
+ render(
+ ,
+ );
+
+ await openApps();
+ await user.click(screen.getByRole("button", { name: SKIP }));
+ await user.click(screen.getByRole("radio", { name: /pro/i }));
+ await user.click(screen.getByRole("button", { name: "继续配置模型" }));
+ await user.click(
+ screen.getByRole("button", { name: "模型启用回执已确认" }),
+ );
+ await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("button", { name: "返回导入" }));
+ await user.click(screen.getByRole("button", { name: "继续配置模型" }));
+ await user.click(
+ screen.getByRole("button", { name: "模型启用回执已确认" }),
+ );
+ expect(setMemoryMode).toHaveBeenCalledTimes(1);
+
+ await act(async () => resolveFirst({ mode: "pro" }));
+ await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
+ expect(finish).not.toHaveBeenCalled();
+
+ await act(async () => resolveSecond({ mode: "pro" }));
+ await waitFor(() =>
+ expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
+ );
+ expect(completeOnboarding).toHaveBeenCalledOnce();
+ });
+
+ it("accepts a new profile receipt within the same Pro generation", async () => {
+ let resolveFirst!: (value: { mode: "pro" }) => void;
+ let resolveSecond!: (value: { mode: "pro" }) => void;
+ setMemoryMode
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ const finish = vi.fn();
+ render(
+ ,
+ );
+
+ await openApps();
+ await user.click(screen.getByRole("button", { name: SKIP }));
+ await user.click(screen.getByRole("radio", { name: /pro/i }));
+ await user.click(screen.getByRole("button", { name: "继续配置模型" }));
+ await user.click(
+ screen.getByRole("button", { name: "模型启用回执已确认" }),
+ );
+ await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
+
+ await user.click(
+ screen.getByRole("button", { name: "第二个模型启用回执已确认" }),
+ );
+ expect(setMemoryMode).toHaveBeenCalledTimes(1);
+
+ await act(async () => resolveFirst({ mode: "pro" }));
+ await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
+ expect(finish).not.toHaveBeenCalled();
+
+ await act(async () => resolveSecond({ mode: "pro" }));
+ await waitFor(() =>
+ expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
+ );
+ expect(completeOnboarding).toHaveBeenCalledOnce();
+ });
+
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode.mockImplementationOnce(
diff --git a/memhub/apps/web/src/OnboardingFlow.tsx b/memhub/apps/web/src/OnboardingFlow.tsx
index 05976c8..989eae5 100644
--- a/memhub/apps/web/src/OnboardingFlow.tsx
+++ b/memhub/apps/web/src/OnboardingFlow.tsx
@@ -112,9 +112,8 @@ export function OnboardingFlow({
const [locationCheck, setLocationCheck] = useState("checking");
const [selected, setSelected] = useState([]);
const [custom, setCustom] = useState(null);
- const [busy, setBusy] = useState<
- "checking" | "submitting" | "leaving" | null
- >(null);
+ const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
+ const [completionBusy, setCompletionBusy] = useState(false);
const [error, setError] = useState(null);
const [itemErrors, setItemErrors] = useState>({});
const [exitDestination, setExitDestination] =
@@ -138,7 +137,10 @@ export function OnboardingFlow({
);
const [modeDestination, setModeDestination] =
useState("sources");
- const [modeSaving, setModeSaving] = useState(false);
+ const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
+ "idle",
+ );
+ const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState(null);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
@@ -181,7 +183,10 @@ export function OnboardingFlow({
)
.join("、"),
});
- const flight = useRef(false);
+ const sourceFlight = useRef(false);
+ const modeOperation = useRef(0);
+ const proConfirmationOwner = useRef(null);
+ const completionFlight = useRef(false);
const connectionAttempt = useRef(null);
const connectionQueue = useRef(Promise.resolve());
const connectionQueueDepth = useRef(0);
@@ -368,7 +373,10 @@ export function OnboardingFlow({
selected.some((chosen) => chosen.source_id === item.source_id),
);
const locked =
- busy !== null || exitDestination !== null || state.status === "importing";
+ busy !== null ||
+ completionBusy ||
+ exitDestination !== null ||
+ state.status === "importing";
useEffect(() => {
alive.current = true;
@@ -456,7 +464,7 @@ export function OnboardingFlow({
locationCheck !== "ready" ||
locked ||
reviewingImport ||
- flight.current
+ sourceFlight.current
)
return;
if (!scopeMatches(selected, sources)) return;
@@ -493,7 +501,11 @@ export function OnboardingFlow({
}, []);
const leaveModelStep = useCallback(
- (nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
+ (
+ nextIntent: OnboardingMemoryMode,
+ nextStep: "review" | "mode",
+ resetModePhase = true,
+ ) => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
@@ -502,7 +514,10 @@ export function OnboardingFlow({
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
- setModeSaving(false);
+ if (resetModePhase) {
+ modeOperation.current += 1;
+ setModePhase("idle");
+ }
},
[],
);
@@ -611,12 +626,15 @@ export function OnboardingFlow({
const leave = useCallback(
async (destination: OnboardingDestination) => {
- if (flight.current || finished.current) return;
- flight.current = true;
- setBusy("leaving");
- await persistFinish(destination);
- flight.current = false;
- if (alive.current) setBusy(null);
+ if (completionFlight.current || finished.current) return;
+ completionFlight.current = true;
+ setCompletionBusy(true);
+ try {
+ await persistFinish(destination);
+ } finally {
+ completionFlight.current = false;
+ if (alive.current) setCompletionBusy(false);
+ }
},
[persistFinish],
);
@@ -640,14 +658,11 @@ export function OnboardingFlow({
);
const saveModeChoice = useCallback(async () => {
- if (
- !modeChoice ||
- modeSaving ||
- finished.current ||
- (modeChoice === "lite" && flight.current)
- )
- return;
- setModeSaving(true);
+ if (!modeChoice || modeSaving || finished.current || completionBusy) return;
+ const operation = ++modeOperation.current;
+ const ownsModeOperation = () =>
+ alive.current && !finished.current && modeOperation.current === operation;
+ setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
@@ -655,8 +670,7 @@ export function OnboardingFlow({
intentRef.current = "pro";
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
- alive.current &&
- !finished.current &&
+ ownsModeOperation() &&
intentRef.current === "pro" &&
setupStepRef.current === "mode";
const savedProgress = await queueProgressWrite(
@@ -673,10 +687,9 @@ export function OnboardingFlow({
enterModelStep();
return;
}
- leaveModelStep("lite", "mode");
+ leaveModelStep("lite", "mode", false);
const liteStillCurrent = () =>
- alive.current &&
- !finished.current &&
+ ownsModeOperation() &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
await queueProgressWrite(
@@ -688,19 +701,20 @@ export function OnboardingFlow({
}),
liteStillCurrent,
);
- if (!alive.current) return;
- const changed = await queueModeWrite("lite");
- if (!alive.current) return;
+ if (!liteStillCurrent()) return;
+ const changed = await queueModeWrite("lite", liteStillCurrent);
+ if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
+ setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
- } else if (alive.current) {
+ } else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
- if (alive.current) setModeSaving(false);
+ if (ownsModeOperation()) setModePhase("idle");
}
}, [
enterModelStep,
@@ -709,6 +723,7 @@ export function OnboardingFlow({
modeChoice,
modeDestination,
modeSaving,
+ completionBusy,
finishOnce,
queueModeWrite,
queueProgressWrite,
@@ -731,6 +746,7 @@ export function OnboardingFlow({
const confirmProMode = useCallback(
async (token: number, ownerProfileId: string) => {
+ const confirmationOwner = `${token}:${ownerProfileId}`;
const ownsModelStep = () =>
alive.current &&
!finished.current &&
@@ -738,16 +754,25 @@ export function OnboardingFlow({
intentRef.current === "pro" &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
- if (!ownsModelStep()) return;
- setModeSaving(true);
+ if (
+ !ownsModelStep() ||
+ proConfirmationOwner.current === confirmationOwner ||
+ completionBusy
+ )
+ return;
+ proConfirmationOwner.current = confirmationOwner;
+ const operation = ++modeOperation.current;
+ const ownsConfirmation = () =>
+ ownsModelStep() && modeOperation.current === operation;
+ setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
- if (!ownsModelStep()) return;
+ if (!ownsConfirmation()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
- const changed = await queueModeWrite("pro", ownsModelStep);
- if (!ownsModelStep()) return;
+ const changed = await queueModeWrite("pro", ownsConfirmation);
+ if (!ownsConfirmation()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await queueProgressWrite(
() =>
@@ -756,31 +781,35 @@ export function OnboardingFlow({
setup_step: "model",
model_profile_id: ownerProfileId,
}),
- ownsModelStep,
+ ownsConfirmation,
);
- if (!ownsModelStep()) return;
- await persistFinish(modeDestination);
+ if (!ownsConfirmation()) return;
+ setModePhase("completing");
+ await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
- } else if (alive.current && !isGenerationInvalid(reason))
+ } else if (ownsConfirmation() && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} finally {
- if (alive.current) setModeSaving(false);
+ if (proConfirmationOwner.current === confirmationOwner)
+ proConfirmationOwner.current = null;
+ if (ownsConfirmation()) setModePhase("idle");
}
},
[
+ completionBusy,
finishOnce,
+ leave,
modeDestination,
- persistFinish,
queueModeWrite,
queueProgressWrite,
],
);
async function checkLocations() {
- if (flight.current || locked) return;
- flight.current = true;
+ if (sourceFlight.current || locked) return;
+ sourceFlight.current = true;
scopeVersion.current += 1;
setBusy("checking");
setLocationCheck("checking");
@@ -805,7 +834,7 @@ export function OnboardingFlow({
setLocationCheck("failed");
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
} finally {
- flight.current = false;
+ sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -813,7 +842,7 @@ export function OnboardingFlow({
function toggleSource(source: SourceResponse, checked: boolean) {
if (
- flight.current ||
+ sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!isAppSource(source) ||
@@ -836,7 +865,7 @@ export function OnboardingFlow({
}
function selectAllSources() {
- if (flight.current || locked || locationCheck !== "ready") return;
+ if (sourceFlight.current || locked || locationCheck !== "ready") return;
scopeVersion.current += 1;
setError(null);
setItemErrors({});
@@ -849,7 +878,7 @@ export function OnboardingFlow({
async function startImport() {
if (
- flight.current ||
+ sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!selected.length
@@ -863,7 +892,7 @@ export function OnboardingFlow({
}
async function submitImport(approved: SourceResponse[]) {
- flight.current = true;
+ sourceFlight.current = true;
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -917,7 +946,7 @@ export function OnboardingFlow({
}
} finally {
connectSent.current = false;
- flight.current = false;
+ sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -987,10 +1016,10 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
- busy={busy === "leaving"}
+ busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
- if (busy === "leaving") return;
+ if (completionBusy) return;
if (completionFailed) {
void leave(destination);
return;
@@ -1031,7 +1060,7 @@ export function OnboardingFlow({
<>
{
setModeError(null);
intentRef.current = modeChoice;
@@ -1056,21 +1085,19 @@ export function OnboardingFlow({
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
- disabled={
- !modeChoice ||
- busy === "leaving" ||
- (modeChoice === "lite" && flight.current)
- }
+ disabled={!modeChoice || completionBusy}
onClick={() => void saveModeChoice()}
>
{text(
- modeSaving
- ? "正在保存…"
- : modeChoice === "lite"
- ? "使用 Lite 进入"
- : modeChoice === "pro"
- ? "继续配置模型"
- : "继续",
+ modePhase === "saving"
+ ? "正在保存模式…"
+ : modePhase === "completing"
+ ? "正在完成首次设置…"
+ : modeChoice === "lite"
+ ? "使用 Lite 进入"
+ : modeChoice === "pro"
+ ? "继续配置模型"
+ : "继续",
)}
>
@@ -1104,7 +1131,7 @@ export function OnboardingFlow({
},
]}
selected={modeChoice}
- disabled={modeSaving || busy === "leaving"}
+ disabled={modeSaving || completionBusy}
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode) => {
modeChoiceTouched.current = true;
@@ -1188,7 +1215,7 @@ export function OnboardingFlow({
<>
finishOnce(exitDestination)}
>
{text(
@@ -1197,7 +1224,7 @@ export function OnboardingFlow({
void leave(exitDestination)}
>
{text("重试保存并进入")}