"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction, } from "react"; import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar"; import type { SidebarSession } from "@/components/sidebar-session-row"; import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; import { readSessions } from "@/lib/home-cloud-sync"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { prefetchHomeWarmDaily, prefetchHomeWarmSnapshot } from "@/lib/home-warm-prefetch"; import { isProfileComplete, readProfile } from "@/lib/home-profile"; import type { Account, ChatSession } from "@/lib/home-types"; import { BOOT_READ_INIT, bootSessionsPath, takeBootSessionsRead, withBootFallback } from "@/lib/boot-early-reads"; import { isListedSidebarSession } from "@/lib/session-list-filter"; import { toSidebarSessionRow } from "@/lib/session-sidebar-row"; import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; export type SessionListBoot = { readonly sessions: ChatSession[]; readonly rawRows: unknown; readonly cursor: string | null; readonly account: Account | null; readonly signedOut: boolean; readonly subjectId?: string; readonly error?: string; }; export type ShellRegistration = { readonly controls: AppSidebarControls; readonly escapeBlocked: boolean; readonly activeSessionId: string | null; readonly openingSessionId: string | null; readonly insetClassName: string; readonly insetInert: boolean; readonly sidebarSessions?: readonly SidebarSession[]; readonly sidebarAccount?: SidebarAccount; }; export type SessionListContextValue = { sessions: ChatSession[]; setSessions: Dispatch>; sessionsCursor: string | null; setSessionsCursor: Dispatch>; account: Account | null; setAccount: Dispatch>; signedOut: boolean; settled: boolean; ready: Promise; waitUntilReady: () => Promise; boot: () => SessionListBoot | null; reload: () => void; registerShellControls: (registration: ShellRegistration | null) => void; }; const SessionListContext = createContext(null); // Home produces these controls; only the shell subscribes to their updates. // Publishing them through the data context re-renders Home, whose registration // effect has fresh callbacks/rows, feeding another update back to the provider. const ShellRegistrationContext = createContext(null); function createReadyGate(): { promise: Promise; resolve: () => void } { let settle = () => {}; const promise = new Promise((resolve) => { settle = resolve; }); return { promise, resolve: () => settle() }; } function toSidebarAccount(account: Account | null): SidebarAccount | null { if (!account) return null; const profileName = account.profile && typeof account.profile === "object" && "name" in account.profile && typeof account.profile.name === "string" ? account.profile.name.trim() : ""; const name = profileName; const email = account.user.email || ""; return { name: name || email || "账户", email, initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你", credits: account.credits, avatar: account.avatar, }; } function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] { return sessions.filter((session) => isListedSidebarSession(session) && sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session)); } function fetchSessionPage(subjectId: string, signal: AbortSignal): Promise { return fetch(bootSessionsPath(subjectId), { ...BOOT_READ_INIT, signal }); } /** * Cold boot reads the account, the people list and the remembered person's * sessions in one round (BUG-1127). The sessions read is a guess at the * person: it is used only when the person is still the same once the people * list has settled the choice; otherwise it is dropped and the right person is * read, so another person's list never paints (task red line 3). */ export async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promise { const guessedSubject = readCurrentSubjectId(); // On `/` the HTML already sent this read (T8); otherwise it starts here. const guessedSessions = withBootFallback(takeBootSessionsRead(guessedSubject), () => fetchSessionPage(guessedSubject, signal)) .then((response) => ({ response, error: null as unknown }), (error: unknown) => ({ response: null, error })); void loadSubjectCatalog(forceCatalog).catch(() => undefined); const accountResult = await readBootAccount(); signal.throwIfAborted(); if (accountResult.status === 401) { clearHomeWarmSnapshot(); invalidateSubjectCatalog(); bindCurrentSubjectAccount("", null); return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; } if (!accountResult.account) throw new Error("account_unavailable"); const accountPayload = accountResult.account; const boundAccount = readCurrentSubjectAccountId(); if (boundAccount !== accountPayload.user.id) { // Another account: nothing of the previous one may paint on `/` (BUG-1040). clearHomeWarmSnapshot(); // With no account bound yet (first sign-in on this device, or after a // sign-out, which already invalidated), there is no stale people list; the // one started above was read with the current session, so keep it instead // of paying a second round for the same data (BUG-1127). if (boundAccount) invalidateSubjectCatalog(); bindCurrentSubjectAccount(accountPayload.user.id, null); } // Joins the read started above unless the account change just invalidated it. await loadSubjectCatalog(); signal.throwIfAborted(); const subjectId = readCurrentSubjectId(); // The guess was made for the account this device remembered; another account's // list is never reused, even for the same person id (task red line 8). const guessUsable = subjectId === guessedSubject && (!boundAccount || boundAccount === accountPayload.user.id); const guessed = guessUsable ? await guessedSessions : null; signal.throwIfAborted(); const sessionResponse = guessed?.response ?? await fetchSessionPage(subjectId, signal); if (sessionResponse.status === 401) { clearHomeWarmSnapshot(); return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; } const sessionPayload = await sessionResponse.json().catch(() => null) as { sessions?: unknown; nextCursor?: unknown; } | null; if (!sessionResponse.ok) throw new Error("sessions_unavailable"); const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : []; const parsed = readSessions(rawRows, null); const cursor = typeof sessionPayload?.nextCursor === "string" ? sessionPayload.nextCursor : null; return { sessions: parsed.sessions, rawRows, cursor, account: accountPayload, signedOut: false, subjectId, }; } export function SessionListProvider({ children }: { children: ReactNode }) { const [sessions, setSessions] = useState([]); const [sessionsCursor, setSessionsCursor] = useState(null); const [account, setAccount] = useState(null); const [signedOut, setSignedOut] = useState(false); const [settled, setSettled] = useState(false); const [registration, setRegistration] = useState(null); const bootRef = useRef(null); const controllerRef = useRef(null); const reloadRef = useRef<() => void>(() => undefined); const [readyPack] = useState(createReadyGate); const currentReady = useRef(readyPack); useCurrentSubject(); useEffect(() => { let disposed = false; let displayedAccount = ""; const load = (nextReadyPack: ReturnType, forceCatalog = false) => { controllerRef.current?.abort(); const controller = new AbortController(); controllerRef.current = controller; setSettled(false); setSignedOut(false); bootRef.current = null; void loadSessionList(controller.signal, forceCatalog) .then((boot) => { if (disposed || controller.signal.aborted || controllerRef.current !== controller) return; const requestedSubject = boot.subjectId ?? readCurrentSubjectId(); if (requestedSubject !== readCurrentSubjectId()) return; bootRef.current = boot; const sameAccount = Boolean(boot.account) && displayedAccount === boot.account?.user.id; setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions, { subjectId: requestedSubject, complete: boot.cursor == null, }) : boot.sessions); displayedAccount = boot.account?.user.id ?? ""; setSessionsCursor(boot.cursor); setAccount(boot.account); setSignedOut(boot.signedOut); setSettled(true); nextReadyPack.resolve(); }) .catch(() => { if (disposed || controller.signal.aborted || controllerRef.current !== controller) return; bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" }; setSettled(true); nextReadyPack.resolve(); }); }; reloadRef.current = () => { const previous = currentReady.current; const next = createReadyGate(); currentReady.current = next; previous.resolve(); invalidateAccountFlight(); load(next, true); }; load(readyPack); const unsubscribe = subscribeCurrentSubject(() => { if (disposed) return; reloadRef.current(); }); return () => { disposed = true; unsubscribe(); reloadRef.current = () => undefined; controllerRef.current?.abort(); }; }, []); // BUG-1104: a tab that opened on a secondary page has no warm snapshot yet, // so its first 「新建对话」 would cold-start Home. Once the list is settled and // no Home is mounted, fill the snapshot in the background (read-only GETs). // Home mounting, sign-out, an account switch or a list reload cancels it. const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile)) ? account.user.id : null; useEffect(() => { if (!prefetchAccountId || window.location.pathname === "/") return; const controller = new AbortController(); const run = () => { const isCurrent = () => !controller.signal.aborted; void prefetchHomeWarmSnapshot(prefetchAccountId, isCurrent, undefined, controller.signal); // Only the account holder's own line: other people's profiles live in the // subject catalog, and a missing line there just keeps the old behaviour. if (readCurrentSubjectId() === "self" && account) { void prefetchHomeWarmDaily(prefetchAccountId, "self", readProfile(account.profile), isCurrent, undefined, controller.signal); } }; // Safari has no requestIdleCallback; a short timeout keeps it off the first paint. const idle = "requestIdleCallback" in window; const handle = idle ? window.requestIdleCallback(run, { timeout: 2000 }) : window.setTimeout(run, 300); return () => { controller.abort(); if (idle) window.cancelIdleCallback(handle); else window.clearTimeout(handle); }; }, [account, prefetchAccountId]); const reload = useCallback(() => reloadRef.current(), []); const registerShellControls = useCallback((next: ShellRegistration | null) => { setRegistration(next); }, []); const boot = useCallback(() => bootRef.current, []); const waitUntilReady = useCallback(async () => { for (;;) { const gate = currentReady.current; await gate.promise; if (gate === currentReady.current) return; } }, []); const ready = readyPack.promise; const value = useMemo(() => ({ sessions, setSessions, sessionsCursor, setSessionsCursor, account, setAccount, signedOut, settled, ready, waitUntilReady, boot, reload, registerShellControls, }), [ account, boot, reload, registerShellControls, sessions, sessionsCursor, settled, signedOut, ready, waitUntilReady, ]); return ( {children} ); } export function useSessionList(): SessionListContextValue { const value = useContext(SessionListContext); if (!value) { throw new Error("useSessionList must be used inside SessionListProvider"); } return value; } export function useShellRegistration(): ShellRegistration | null { return useContext(ShellRegistrationContext); } export function sessionListSidebarModel( list: SessionListContextValue, registered: ShellRegistration | null, ): { sessions: readonly SidebarSession[]; account: SidebarAccount | null; } { return { sessions: registered?.sidebarSessions ?? sessionsToSidebarRows(list.sessions), account: registered?.sidebarAccount ?? toSidebarAccount(list.account), }; }