Files
Jyotisha/frontend/src/lib/session-list-context.tsx
T
jesse-uxandClaude Code 2c92f6100e fix(frontend): isolate sidebar registration to unblock navigation
Separate shell subscriptions from Home session data and cover effect convergence, transition cleanup, current callbacks and signed-out fallback with real React lifecycle tests. Record baseline-equivalent local failures and outstanding browser/build verification.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 14:32:42 +08:00

217 lines
7.2 KiB
TypeScript

"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 { readSessions } from "@/lib/home-cloud-sync";
import type { Account, ChatSession } from "@/lib/home-types";
import { SESSION_PAGE_SIZE } from "@/lib/session-cursor";
import { isListedSidebarSession } from "@/lib/session-list-filter";
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
export type SessionListBoot = {
readonly sessions: ChatSession[];
readonly rawRows: unknown;
readonly draftRow: unknown;
readonly cursor: string | null;
readonly account: Account | null;
readonly signedOut: boolean;
};
export type ShellRegistration = {
readonly controls: AppSidebarControls;
readonly escapeBlocked: boolean;
readonly activeSessionId: string | null;
readonly openingSessionId: string | null;
readonly openErrorSessionId: string | null;
readonly openErrorMessage: string;
readonly insetClassName: string;
readonly insetInert: boolean;
readonly sidebarSessions?: readonly SidebarSession[];
readonly sidebarAccount?: SidebarAccount;
};
export type SessionListContextValue = {
sessions: ChatSession[];
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
sessionsCursor: string | null;
setSessionsCursor: Dispatch<SetStateAction<string | null>>;
account: Account | null;
setAccount: Dispatch<SetStateAction<Account | null>>;
signedOut: boolean;
settled: boolean;
ready: Promise<void>;
boot: () => SessionListBoot | null;
registerShellControls: (registration: ShellRegistration | null) => void;
};
const SessionListContext = createContext<SessionListContextValue | null>(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<ShellRegistration | null>(null);
function createReadyGate(): { promise: Promise<void>; resolve: () => void } {
let settle = () => {};
const promise = new Promise<void>((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(isListedSidebarSession).map((session) => toSidebarSessionRow(session));
}
async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
const [sessionResponse, accountResponse] = await Promise.all([
fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}`, { signal, cache: "no-store" }),
fetch("/api/account", { signal, cache: "no-store" }),
]);
if (sessionResponse.status === 401 || accountResponse.status === 401) {
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: true };
}
const sessionPayload = await sessionResponse.json().catch(() => null) as {
sessions?: unknown;
nextCursor?: unknown;
draft?: unknown;
} | null;
const accountPayload = await accountResponse.json().catch(() => null);
if (!sessionResponse.ok || !accountResponse.ok) {
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
}
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,
draftRow: sessionPayload?.draft ?? null,
cursor,
account: accountPayload as Account,
signedOut: false,
};
}
export function SessionListProvider({ children }: { children: ReactNode }) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const [account, setAccount] = useState<Account | null>(null);
const [signedOut, setSignedOut] = useState(false);
const [settled, setSettled] = useState(false);
const [registration, setRegistration] = useState<ShellRegistration | null>(null);
const bootRef = useRef<SessionListBoot | null>(null);
const [readyPack] = useState(createReadyGate);
useEffect(() => {
const controller = new AbortController();
void loadSessionList(controller.signal)
.then((boot) => {
if (controller.signal.aborted) return;
bootRef.current = boot;
setSessions(boot.sessions);
setSessionsCursor(boot.cursor);
setAccount(boot.account);
setSignedOut(boot.signedOut);
setSettled(true);
readyPack.resolve();
})
.catch(() => {
if (controller.signal.aborted) return;
bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
setSettled(true);
readyPack.resolve();
});
return () => controller.abort();
}, [readyPack]);
const registerShellControls = useCallback((next: ShellRegistration | null) => {
setRegistration(next);
}, []);
const boot = useCallback(() => bootRef.current, []);
const value = useMemo<SessionListContextValue>(() => ({
sessions,
setSessions,
sessionsCursor,
setSessionsCursor,
account,
setAccount,
signedOut,
settled,
ready: readyPack.promise,
boot,
registerShellControls,
}), [
account,
boot,
readyPack.promise,
registerShellControls,
sessions,
sessionsCursor,
settled,
signedOut,
]);
return (
<SessionListContext.Provider value={value}>
<ShellRegistrationContext.Provider value={registration}>
{children}
</ShellRegistrationContext.Provider>
</SessionListContext.Provider>
);
}
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),
};
}