Files
Jyotisha/frontend/src/lib/home-bootstrap-run.ts
T
Jesse_ChenandClaude Opus 5.5 0a8350cc01 fix(home): one new-chat intent and a scoped login-return stash (BUG-1038)
Coming back to / from /people could show the previous rectification session
as a locked page: its title in the header, the composer stuck on
"正在打开生时校正…", and the new-chat greeting in the middle.

The state that survived between pages is the sessionStorage login-return
stash that secondary-page sidebar links write from the current ?c=:
- /people「和 TA 对话」used a second intent (?newChat=1) parsed by a
  component mounted inside Home after bootstrap, so the bootstrap new-chat
  branch never ran and the stash won.
- A stash id not in the current person's loaded list was looked up and
  landed with urlAction "keep", which assumes ?c= is already in the address
  bar. It was not, so the rectification auto-open never fired. The stash
  also ignored which person was current.
- An in-page new chat left the stash in place.

Fix: delete NewChatDeepLink / ?newChat and route「和 TA 对话」through
newChatHref(); a looked-up stash writes ?c= back (replace-selected) and is
dropped when it belongs to another person; startNewChat and
openChatBoundToProfile clear the stash. ?c= deep links, BUG-989 and BUG-705
are unchanged.

Tests: new real-lifecycle suite mounting the real Home, sidebar and people
page (10 cases: four secondary pages + mobile drawer, 和 TA 对话 for self and
another person, out-of-scope stash, same-person stash beyond the first page,
in-page new chat), plus two contract/unit tests. Six fail on origin/staging,
all pass here. Full suite 3928 / 61 failing, failure names identical to the
0ab061b9 baseline (3916 / 61).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 07:23:50 +08:00

482 lines
19 KiB
TypeScript

/**
* Home cloud bootstrap. No React hooks: Home owns one effect and passes setters.
*
* The 20s timeout aborts this run and reveals the fatal screen. A login redirect
* leaves that timer running so a failed navigation can still escape the spinner.
*/
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import {
guidedBirthTimePreview,
isGuidedBirthTimePreview,
previewRectificationJourney,
} from "@/lib/birth-time-guided-preview";
import { hydrateDeclaredWindowDraft } from "@/lib/birth-time-intake-model";
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import {
SESSION_MISSING_NOTICE,
clearLoginSessionReturn,
writeSessionUrl,
type BootstrapSessionSelection,
} from "@/lib/chat-session-url";
import { createStartGreeting } from "@/lib/starter-greeting";
import {
HOME_BOOTSTRAP_SLOW_MS,
resolveStarterHomeLandingSessionId,
starterHomeLandingNeedsConsultation,
type BootstrapPhase,
} from "@/lib/home-bootstrap";
import {
ConsultationStatusError,
LoginRedirectError,
applyLegacySessionControls,
createSession,
fetchActiveConsultationStatus,
fetchConsultationStatus,
fetchModelCatalog,
fetchSessionDetail,
friendlyError,
patchSessionModel,
readSessions,
readStoredPendingConsultation,
redirectToLogin,
resolveLookupBootstrap,
} from "@/lib/home-cloud-sync";
import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile";
import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import {
emptyProfile,
pendingConsultationStorageKey,
previewModelCatalog,
timestamp,
type Account,
type ChatSession,
type ConsultationStatus,
type OnboardingStep,
type Profile,
type SessionReadResult,
type StoredPendingConsultation,
} from "@/lib/home-types";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard";
import type { SessionListBoot } from "@/lib/session-list-context";
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
export const HOME_BOOTSTRAP_TIMEOUT_MESSAGE = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。";
export type HomeBootstrapStorage = Pick<Storage, "getItem" | "removeItem">;
export type HomeBootstrapCommit = {
setAccount: (account: Account) => void;
setAccountError: (value: string) => void;
setActiveSessionId: (id: string) => void;
setBirthTimeAssessmentPhase: (phase: BirthTimeAssessmentPhase | null) => void;
setBirthTimeJourney: (journey: JourneyClientResponse | null) => void;
setBootstrapPhase: (phase: BootstrapPhase) => void;
setGuidedJourneyPreview: (value: boolean) => void;
setHydrated: (value: boolean) => void;
setModelCatalog: (catalog: PublicLanguageModelCatalog | null) => void;
setOnboardingStep: (step: OnboardingStep) => void;
setProfile: (profile: Profile) => void;
setProfileDraft: (profile: Profile) => void;
setSessions: (sessions: ChatSession[]) => void;
setSessionsCursor: (cursor: string | null) => void;
setStartGreeting: (value: string) => void;
};
export type HomeBootstrapIo = {
fetchModelCatalog: typeof fetchModelCatalog;
fetchActiveConsultationStatus: typeof fetchActiveConsultationStatus;
fetchConsultationStatus: typeof fetchConsultationStatus;
fetchSessionDetail: typeof fetchSessionDetail;
resolveLookupBootstrap: typeof resolveLookupBootstrap;
patchSessionModel: typeof patchSessionModel;
redirectToLogin: typeof redirectToLogin;
clearStaleClientReload: typeof clearStaleClientReload;
writeSessionUrl: typeof writeSessionUrl;
clearLoginSessionReturn: typeof clearLoginSessionReturn;
restoreConsultationRecovery: (
session: ChatSession,
requestId: string,
stored?: StoredPendingConsultation | null,
) => void;
};
export type HomeBootstrapDeps = {
sessionListReady: Promise<void>;
waitForSessionList?: () => Promise<void>;
sessionListBoot: () => SessionListBoot | null;
uiPreview: { current: boolean };
uiPreviewMode: { current: string | null };
consultationStatusMissingCount: { current: number };
isDevelopment: boolean;
readSearch: () => string;
storage: HomeBootstrapStorage;
restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"];
setComposerNotice: (notice: string) => void;
commit: HomeBootstrapCommit;
io?: Partial<HomeBootstrapIo>;
};
type AccountPhaseReady = {
kind: "ready";
nextAccount: Account;
nextModelCatalog: PublicLanguageModelCatalog | null;
nextProfile: Profile;
nextSessions: ChatSession[];
parsedSessions: SessionReadResult;
reservedConsultation: ConsultationStatus | null;
storedPending: StoredPendingConsultation | null;
cursor: string | null;
};
type AccountPhaseResult = AccountPhaseReady | { kind: "stop" };
const defaultIo: Omit<HomeBootstrapIo, "restoreConsultationRecovery"> = {
fetchModelCatalog,
fetchActiveConsultationStatus,
fetchConsultationStatus,
fetchSessionDetail,
resolveLookupBootstrap,
patchSessionModel,
redirectToLogin,
clearStaleClientReload,
writeSessionUrl,
clearLoginSessionReturn,
};
function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo {
return {
...defaultIo,
...deps.io,
restoreConsultationRecovery: deps.io?.restoreConsultationRecovery ?? deps.restoreConsultationRecovery,
};
}
export async function runAccountPhase(
deps: HomeBootstrapDeps,
signal: AbortSignal,
): Promise<AccountPhaseResult> {
const io = resolveIo(deps);
const previewMode = deps.isDevelopment
? new URLSearchParams(deps.readSearch()).get("preview")
: null;
if (previewMode) {
deps.uiPreview.current = true;
deps.uiPreviewMode.current = previewMode;
deps.commit.setGuidedJourneyPreview(true);
if (previewMode === "error") {
deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
deps.commit.setHydrated(true);
return { kind: "stop" };
}
const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading";
const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete";
const isRectificationPreview = isGuidedBirthTimePreview(previewMode);
const previewJourney = isRectificationPreview
? guidedBirthTimePreview(previewMode)
: previewRectificationJourney;
const previewProfile: Profile = previewMode === "onboarding"
? emptyProfile
: {
...emptyProfile,
name: "林遥",
date: "1990-06-15",
time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30",
reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30",
birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import",
...hydrateDeclaredWindowDraft({
period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
}),
birthTimeClue: "",
uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null,
uncertaintyAfterMinutes: isRectificationPreview ? 30 : null,
birthTimeStatus: isCompletedCandidatePreview
? "candidate"
: isAssessmentLoadingPreview
? "rectifying"
: previewJourney.snapshot.state === "candidate"
|| previewJourney.snapshot.state === "confirming"
|| previewJourney.snapshot.state === "ready"
? "candidate"
: isRectificationPreview ? "rectifying" : "confirmed",
rectificationCaseId: isRectificationPreview ? previewJourney.caseId : "",
countryCode: "CN",
provinceCode: "110000",
cityCode: "110000-city",
districtCode: "110101",
};
const previewMessages = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial"
? [
{ role: "user" as const, text: "未来半年是否适合换工作?" },
{ role: "assistant" as const, text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" },
]
: [];
const previewSession: ChatSession = {
id: "preview-session",
title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话",
theme: "career",
modelId: previewModelCatalog.defaultModelId,
messages: previewMessages,
// session-clock-exempt: development preview fixture, not a stored session
updatedAt: timestamp(),
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: "self",
chartProfileName: previewProfile.name.trim() || "我",
chartProfileRole: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
};
deps.commit.setAccount({
user: { id: "preview-user", email: "preview@local.test" },
avatar: null,
credits: 8,
isAdmin: false,
adminUrl: null,
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed",
activeSubscription: null,
profile: previewProfile,
});
deps.commit.setModelCatalog(previewModelCatalog);
deps.commit.setProfile(previewProfile);
deps.commit.setProfileDraft(previewProfile);
if (isRectificationPreview) {
deps.commit.setBirthTimeJourney(previewJourney);
}
if (isAssessmentLoadingPreview) {
deps.commit.setBirthTimeAssessmentPhase("assessing");
}
deps.commit.setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name");
deps.commit.setSessions([previewSession]);
deps.commit.setActiveSessionId(previewSession.id);
const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : "";
deps.commit.setStartGreeting(previewGreeting);
deps.commit.setHydrated(true);
return { kind: "stop" };
}
// Start independent reads before waiting for the provider so slow-network
// latency is spent in parallel, not as a serial account -> model chain.
const modelCatalogPromise = io.fetchModelCatalog(signal)
.then((catalog) => ({ catalog, error: null as unknown }))
.catch((error: unknown) => ({ catalog: null, error }));
const activeStatusPromise = io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return null;
});
await (deps.waitForSessionList?.() ?? deps.sessionListReady);
if (signal.aborted) return { kind: "stop" };
const listBoot = deps.sessionListBoot();
if (listBoot?.signedOut) {
io.redirectToLogin();
return { kind: "stop" };
}
if (!listBoot?.account || listBoot.error) throw new Error("session_list_unavailable");
const nextAccount = listBoot.account;
const modelCatalogResult = await modelCatalogPromise;
if (modelCatalogResult.error) throw modelCatalogResult.error;
const nextModelCatalog = modelCatalogResult.catalog;
const nextProfile = readProfile(nextAccount.profile);
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
if (nextSessions.length === 0) {
nextSessions = [createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], nextProfile),
)];
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = await activeStatusPromise;
const storedPending = dropRectificationStoredPending(
readStoredPendingConsultation(deps.storage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
nextSessions,
);
if (!storedPending) deps.storage.removeItem(pendingConsultationStorageKey);
if (storedPending) {
try {
const status = await io.fetchConsultationStatus(
storedPending.sessionId,
storedPending.requestId,
signal,
);
if (status.status === "reserved") {
deps.consultationStatusMissingCount.current = 0;
reservedConsultation = status;
} else {
deps.storage.removeItem(pendingConsultationStorageKey);
reservedConsultation = null;
}
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
deps.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
reservedConsultation = {
sessionId: storedPending.sessionId,
requestId: storedPending.requestId,
status: "reserved",
};
}
}
if (signal.aborted) return { kind: "stop" };
return {
kind: "ready",
nextAccount,
nextModelCatalog,
nextProfile,
nextSessions,
parsedSessions,
reservedConsultation,
storedPending,
cursor: listBoot.cursor,
};
}
export async function resolveLanding(
deps: HomeBootstrapDeps,
signal: AbortSignal,
ready: AccountPhaseReady,
): Promise<void> {
const io = resolveIo(deps);
const { writeSessionUrl, clearLoginSessionReturn, restoreConsultationRecovery } = io;
const lookedUp = await io.resolveLookupBootstrap({
sessions: ready.nextSessions,
catalog: ready.nextModelCatalog,
defaultSessionId: ready.nextSessions[0].id,
signal,
inSubjectScope: (session) => sessionMatchesSubject(session, readCurrentSubjectId()),
});
let nextSessions = lookedUp.sessions;
const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection;
let landingSessionId = resolveStarterHomeLandingSessionId(
nextSessions,
bootstrapSelection.sessionId,
bootstrapSelection.urlAction,
);
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
const homeSession = createSession(
ready.nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], ready.nextProfile),
);
nextSessions = [homeSession, ...nextSessions];
landingSessionId = homeSession.id;
}
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
?? nextSessions[0];
if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") {
try {
const detailed = await io.fetchSessionDetail(activeListed.id, ready.nextModelCatalog, signal);
if (detailed) {
nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session);
}
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
if (caught instanceof LoginRedirectError) return;
}
}
if (signal.aborted) return;
io.clearStaleClientReload(deps.storage);
deps.commit.setSessionsCursor(ready.cursor);
deps.commit.setAccount(ready.nextAccount);
deps.commit.setModelCatalog(ready.nextModelCatalog);
deps.commit.setProfile(ready.nextProfile);
deps.commit.setProfileDraft(ready.nextProfile);
deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : "");
deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile) ?? "name");
deps.commit.setSessions(nextSessions);
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
deps.commit.setActiveSessionId(landingSessionId);
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
if (bootstrapSelection.urlAction === "replace-selected") {
const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId);
if (!selected || !isUnsavedEmptyConsultation(selected)) {
writeSessionUrl(bootstrapSelection.sessionId, "replace");
}
}
const reservedConsultation = ready.reservedConsultation;
const storedPending = ready.storedPending;
if (reservedConsultation?.status === "reserved") {
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
if (bootstrapSelection.urlAction === "new-chat") {
deps.commit.setActiveSessionId(landingSessionId);
deps.setComposerNotice("");
}
}
if (ready.parsedSessions.fallbackSessionIds.length > 0) {
deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
} else if (lookedUp.notice) {
deps.setComposerNotice(lookedUp.notice);
} else if (bootstrapSelection.missing) {
deps.setComposerNotice(SESSION_MISSING_NOTICE);
}
deps.commit.setAccountError("");
const fallbackCatalog = ready.nextModelCatalog;
if (fallbackCatalog && ready.parsedSessions.fallbackSessionIds.length > 0) {
try {
await Promise.all(ready.parsedSessions.fallbackSessionIds.map((sessionId) =>
io.patchSessionModel(sessionId, fallbackCatalog.defaultModelId, signal),
));
} catch {
if (!signal.aborted) {
deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
}
}
}
}
export async function runHomeBootstrap(deps: HomeBootstrapDeps, signal: AbortSignal): Promise<void> {
const controller = new AbortController();
const onParentAbort = () => controller.abort();
if (signal.aborted) onParentAbort();
else signal.addEventListener("abort", onParentAbort);
const bootstrapTimeout = window.setTimeout(() => {
if (controller.signal.aborted) return;
controller.abort();
deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
deps.commit.setHydrated(true);
}, HOME_BOOTSTRAP_SLOW_MS);
const clearTimeoutOnParentAbort = () => {
window.clearTimeout(bootstrapTimeout);
};
if (signal.aborted) clearTimeoutOnParentAbort();
else signal.addEventListener("abort", clearTimeoutOnParentAbort);
let redirectedToLogin = false;
let bootstrapFailed = false;
try {
const ready = await runAccountPhase(deps, controller.signal);
if (ready.kind === "ready") await resolveLanding(deps, controller.signal, ready);
} catch (caught) {
if (caught instanceof LoginRedirectError) {
redirectedToLogin = true;
return;
}
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
bootstrapFailed = true;
deps.commit.setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
}
} finally {
if (redirectedToLogin) return;
signal.removeEventListener("abort", onParentAbort);
signal.removeEventListener("abort", clearTimeoutOnParentAbort);
window.clearTimeout(bootstrapTimeout);
if (!controller.signal.aborted) {
if (bootstrapFailed) deps.commit.setHydrated(true);
else deps.commit.setBootstrapPhase("prepare");
}
}
}