fix(memhub): let Lite onboarding complete immediately

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kristinheandCursor committed 2026-11-05 01:29:45 +08:00
1 parent df5a65797c
commit 996368ee32
2 files changed
+373 -71

No files matched your search

+276 -1
View File
@@ -100,6 +100,18 @@ vi.mock("./OnboardingModelController", () => ({
>
模型启用回执已确认
</button>
<button
type="button"
onClick={() => {
onProfilePersisted("8be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"8be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
第二个模型启用回执已确认
</button>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
@@ -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<ImportSourcesController["connectForOnboarding"]>
>,
) => void;
const controller = imports({
connectForOnboarding: vi.fn(
() =>
new Promise<
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
>((resolve) => {
resolveImport = resolve;
}),
),
});
const finish = vi.fn();
setMemoryMode.mockResolvedValueOnce({ mode: "lite" });
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
render(
<OnboardingFlow
state={onboarding}
imports={controller}
onFinish={finish}
/>,
);
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(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
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(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
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(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
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(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
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(
+97 -70
View File
@@ -112,9 +112,8 @@ export function OnboardingFlow({
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
const [selected, setSelected] = useState<SourceResponse[]>([]);
const [custom, setCustom] = useState<SourceResponse | null>(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<string | null>(null);
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const [exitDestination, setExitDestination] =
@@ -138,7 +137,10 @@ export function OnboardingFlow({
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
"idle",
);
const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState<string | null>(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<string | null>(null);
const completionFlight = useRef(false);
const connectionAttempt = useRef<string | null>(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({
<>
<R6Button
variant="quiet"
disabled={modeSaving || busy === "leaving"}
disabled={modeSaving || completionBusy}
onClick={() => {
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"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
@@ -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({
<>
<R6Button
variant="quiet"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => finishOnce(exitDestination)}
>
{text(
@@ -1197,7 +1224,7 @@ export function OnboardingFlow({
</R6Button>
<R6Button
variant="primary"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => void leave(exitDestination)}
>
{text("重试保存并进入")}