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>
217 lines
7.2 KiB
TypeScript
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),
|
|
};
|
|
}
|