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("重试保存并进入")}