/** * Home bootstrap reveal policy. * * The loading screen owns every wait before the page appears. Phase "account" * fetches account, model catalog and the session list; phase "prepare" fetches * today's starlanguage and the rectification entry summary in parallel. The * page is revealed once every applicable item has settled, or when the prepare * budget runs out — never with a spinner inside. */ import { parseSessionUrlQuery } from "./chat-session-url.ts"; export type BootstrapPhase = "account" | "prepare"; export type LandingSession = Readonly<{ id: string; sessionType: string; messages: readonly unknown[]; /** False for a list row whose messages were not loaded; absent means loaded. */ messagesHydrated?: boolean; archivedAt?: string | null; }>; export type LandingUrlAction = "keep" | "replace-clear" | "none" | "lookup" | "new-chat"; /** * A rectification Case is opened only when the address bar names that session * (`?c=`), including refresh. Bare `/` after login must not open the latest * history item in the background. */ export function shouldAutoOpenRectificationSession(input: { readonly sessionType: string | undefined; readonly sessionId: string | undefined; readonly search: string; }): boolean { if (input.sessionType !== "birth_time_rectification" || !input.sessionId) return false; return parseSessionUrlQuery(input.search).sessionId === input.sessionId; } /** An unsaved empty consultation: the blank starter home, not a conversation. */ export function isStarterHomeDraft(session: LandingSession): boolean { return session.sessionType === "consultation" && !session.archivedAt && session.messagesHydrated !== false && session.messages.length === 0; } /** * Bare `/` (and a dead `?c=`) lands on the blank starter home, never on an old * conversation (BUG-1052; replaces the BUG-599 rule that only swapped away * from a rectification session and otherwise opened the latest chat). An * empty draft already in memory is reused; otherwise * `starterHomeLandingNeedsConsultation` asks for a new local one. `?c=`, * its lookup and `?new=1` keep the requested session. */ export function resolveStarterHomeLandingSessionId( sessions: readonly LandingSession[], selectedId: string, urlAction: LandingUrlAction, ): string { if (urlAction === "keep" || urlAction === "lookup" || urlAction === "new-chat") return selectedId; return sessions.find(isStarterHomeDraft)?.id ?? selectedId; } export function starterHomeLandingNeedsConsultation( sessions: readonly LandingSession[], landingId: string, urlAction: LandingUrlAction, ): boolean { if (urlAction === "new-chat") return true; if (urlAction === "keep" || urlAction === "lookup") return false; const landing = sessions.find((session) => session.id === landingId); return !landing || !isStarterHomeDraft(landing); } /** * Whether a background answer being recovered may take the screen. Only when * the address bar named a conversation; the blank home and an explicit new * chat stay put while the older answer keeps recovering (BUG-1015/1052). */ export function landingYieldsToRecovery(urlAction: LandingUrlAction): boolean { return urlAction === "keep" || urlAction === "lookup"; } export const HOME_BOOTSTRAP_SLOW_MS = 20_000; export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000; export const SESSION_PREFETCH_COUNT = 5; export function retryHomeBootstrap( setAccountError: (value: string) => void, setHydrated: (value: boolean) => void, setBootstrapPhase: (value: BootstrapPhase) => void, prepareStartedAt: { current: number | null }, reloadSessionList: () => void, ): void { setAccountError(""); setHydrated(false); setBootstrapPhase("account"); prepareStartedAt.current = null; reloadSessionList(); } export type BootstrapPrepareState = Readonly<{ dailyStarlanguageApplicable: boolean; dailyStarlanguageSettled: boolean; entrySummarySettled: boolean; /** A URL-named rectification session must be opened and hydrated before reveal. */ rectificationApplicable?: boolean; rectificationSettled?: boolean; }>; export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean { if (!state.entrySummarySettled) return false; if (state.rectificationApplicable && !state.rectificationSettled) return false; if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false; return true; } export function bootstrapRevealDelayMs(now: number, prepareStartedAt: number | null, settled: boolean): number { if (settled) return 0; if (prepareStartedAt === null) return BOOTSTRAP_PREPARE_TIMEOUT_MS; return Math.max(0, BOOTSTRAP_PREPARE_TIMEOUT_MS - (now - prepareStartedAt)); } export function bootstrapLoadingCopy(phase: BootstrapPhase): Readonly<{ title: string; detail: string }> { return phase === "prepare" ? { title: "正在准备对话", detail: "整理今日星语与对话记录" } : { title: "正在载入账户", detail: "同步个人资料与对话记录" }; } export type PrefetchableSession = Readonly<{ id: string; sessionType: string; messagesHydrated?: boolean; }>; export function sessionIdsToPrefetch( sessions: readonly PrefetchableSession[], activeSessionId: string, limit = SESSION_PREFETCH_COUNT, ): string[] { const ids: string[] = []; for (const session of sessions) { if (ids.length >= limit) break; if (session.id === activeSessionId) continue; if (session.messagesHydrated) continue; if (session.sessionType !== "consultation") continue; ids.push(session.id); } return ids; }