Files
Jyotisha/frontend/src/lib/home-bootstrap.ts
T
Jesse_ChenandClaude Opus 5.5 d2551437c4 fix(home): land on the blank starter home after login and on bare / (BUG-1052)
Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return
stash and the "latest session" default landing:

- A full load of / without ?c= (login, typed address, bookmark, refresh of
  bare /) lands on the current person's blank starter home; an existing
  empty draft of that person is reused, otherwise one is created locally.
- ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=.
- The login-return stash is removed: redirectToLogin and sidebar links no
  longer write it, bootstrap no longer reads it and clears a leftover value
  once. replace-selected, the lookup origin/other-subject branch and the warm
  resumeRectification dependency go with it.
- A background answer being recovered no longer takes over the blank home
  (same rule BUG-1015 set for ?new=1).

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

152 lines
5.5 KiB
TypeScript

/**
* 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;
}