"use client"; import { Menu } from "@base-ui/react/menu"; import { CalendarDays, ChevronRight, FileText, LogOut, Orbit, Settings, WalletCards, MessageSquareText, SquarePen, Star, UserRound, Users, } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useRef } from "react"; import type { Ref } from "react"; import { AppLink } from "@/components/app-link"; import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; import { prefetchSecondaryPage } from "@/lib/secondary-page-data"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuLink, SidebarRail, useSidebar, } from "@/components/ui/sidebar"; import { SidebarSessionRow, type SidebarSession, type SidebarSessionControls, } from "@/components/sidebar-session-row"; import { ThemePreferenceMenu } from "@/components/theme-preference-menu"; import { UserAvatar } from "@/components/user-avatar"; import type { BeamAvatar } from "@/lib/beam-avatar"; import { groupSessionsByRecency } from "@/lib/session-groups"; export type SidebarAccount = { name: string; email: string; credits: number; initial: string; avatar: BeamAvatar | null; }; /** * Everything only `/` can supply: the session-management layer with its * optimistic updates and rollback, and the account menu with the settings * dialogs behind it. Omit `controls` and the same sidebar renders read-only — * rows are links, the footer is a link, nothing writes. * * `/chart`, `/ephemeris` and `/reports` used to carry a second sidebar * component of their own, deleted in this round. Two components meant two sets * of markup: its rows had no `.session-main`, so they lost the 44px minimum, * the padding and the 2px current-item marker, while `.session-row` still * reserved 44px for a menu button that was never rendered. Read-only was the * requirement; a different shape was never part of it. * See TASK-sidebar-unify-20260916 D1. */ export type AppSidebarControls = { accountMenuOpen: boolean; accountTriggerRef: Ref; newChatDisabled: boolean; creatingSession: boolean; sessionControls: SidebarSessionControls; onAccountMenuOpenChange: (open: boolean) => void; onNewChat: () => void; onSelectSession: (sessionId: string) => void; onOpenProfile: () => void; onOpenChartLibrary: () => void; onOpenGeneral: () => void; onOpenBilling: () => void; onOpenLogout: () => void; }; export type AppSidebarProps = { sessions: readonly SidebarSession[]; activeSessionId: string | null; /** A rectification session whose Case is being opened and hydrated; its row says so, statically. */ openingSessionId?: string | null; /** Open failed for this history session; shown under that row only. */ openErrorSessionId?: string | null; openErrorMessage?: string; account: SidebarAccount | null; /** Read-only mode: both reads have settled, however they settled. */ settled?: boolean; /** Read-only mode: no session, so the footer offers 去登录 instead of an account. */ signedOut?: boolean; controls?: AppSidebarControls; }; const NAV_PAGES = [ { href: "/chart", label: "星盘", icon: Orbit }, { href: "/ephemeris", label: "星历", icon: CalendarDays }, { href: "/reports", label: "我的报告", icon: FileText }, ] as const; export function AppSidebar({ sessions, activeSessionId, openingSessionId = null, openErrorSessionId = null, openErrorMessage = "", account, settled = true, signedOut = false, controls, }: AppSidebarProps) { const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); /* Null outside an app-router context (and in unit tests that mount this component directly), even though the type says string. */ const pathname = usePathname() ?? ""; const firstSessionRef = useRef(null); const historyHeadingRef = useRef(null); const loadMoreRef = useRef(null); const isCollapsedDesktop = state === "collapsed" && !isMobile; const showExpandedContent = !isCollapsedDesktop; const menuPlacement = `${viewport}:${state}`; const previousMenuPlacement = useRef(menuPlacement); /* Destructured rather than read as `controls.x` at each use site: the React Compiler rule treats every member of an object that carries a ref as a ref read during render, and `accountTriggerRef` lives in here. */ const { accountMenuOpen = false, accountTriggerRef, newChatDisabled = false, creatingSession = false, sessionControls, onAccountMenuOpenChange, onNewChat, onSelectSession, onOpenProfile, onOpenChartLibrary, onOpenGeneral, onOpenBilling, onOpenLogout, }: Partial = controls ?? {}; const hasMoreSessions = sessionControls?.hasMore ?? false; const onLoadMoreSessions = sessionControls?.onLoadMore; const favoriteSessions = sessions.filter((session) => session.pinned); const historySessions = sessions.filter((session) => !session.pinned); const historyGroups = groupSessionsByRecency(historySessions); useEffect(() => { if (!hasMoreSessions || !onLoadMoreSessions) return; const node = loadMoreRef.current; if (!node) return; const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) onLoadMoreSessions(); }); observer.observe(node); return () => observer.disconnect(); }, [historySessions.length, hasMoreSessions, onLoadMoreSessions]); useEffect(() => { if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) { onAccountMenuOpenChange?.(false); } previousMenuPlacement.current = menuPlacement; }, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]); function closeDrawer() { if (isMobile) setOpenMobile(false); } function handleNewChat() { onNewChat?.(); closeDrawer(); } /* Real links. Leaving `/` used to be a full document load: the whole React tree, the session list and the account went with it, so coming back re-ran the entire bootstrap from zero. `/login` keeps the hard exit — it crosses an auth boundary — and `persistLoginSessionReturn()` still stashes the `?c=` first, so the way back to this session survives. */ function leaveChat() { persistLoginSessionReturn(); closeDrawer(); } function handleExpandHistory() { setOpen(true); window.requestAnimationFrame(() => { (firstSessionRef.current ?? historyHeadingRef.current)?.focus(); }); } function renderSession(session: SidebarSession, index: number) { const shared = { session, active: session.id === activeSessionId, opening: session.id === openingSessionId, error: session.id === openErrorSessionId && openErrorMessage ? openErrorMessage : undefined, }; return ( {sessionControls && onSelectSession ? ( sessionControls.onMenuSessionChange(open ? session.id : null)} onSelect={() => { onSelectSession(session.id); closeDrawer(); }} onTogglePinned={() => sessionControls.onTogglePinned(session.id)} onRename={() => sessionControls.onRename(session.id)} onShare={() => sessionControls.onShare(session.id)} onToggleArchived={() => sessionControls.onToggleArchived(session.id)} onDelete={() => sessionControls.onDelete(session.id)} /> ) : ( )} ); } return (
{controls ? ( ) : ( )} {NAV_PAGES.map(({ href, label, icon: Icon }) => ( prefetchSecondaryPage(href)} onPointerDown={() => prefetchSecondaryPage(href)} onClick={leaveChat} > ))}
{showExpandedContent ? ( {/* One flat list. It was three stacked sections — a 星盘列表 chip grid and two
wrappers for 收藏对话 / 历史对话 — so the nav carried two collapse affordances before the first session row. Pinned sessions keep their priority as the first labelled group, in the same shape as the recency groups under them. */}

{sessionControls?.showingArchived ? "归档记录" : "最近"}

{favoriteSessions.length === 0 && historySessions.length === 0 ? ( /* Static copy while a read-only list is in flight — never a skeleton. */

{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}

) : ( {favoriteSessions.length > 0 ? (

{favoriteSessions.map((session, index) => renderSession(session, index))}
) : null} {historyGroups.map((group) => (

{group.label}

{group.sessions.map((session) => renderSession( session, favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id), ))}
))} {hasMoreSessions ?
: null} )} ) : ( )} {controls && account ? ( {account.avatar ? : } {showExpandedContent ? {account.name} : null} {showExpandedContent ?
{account.avatar ? : } {account.name}{account.email}
) : signedOut ? ( /* `/login` crosses an auth boundary, so it stays a document load. */ {showExpandedContent ? 去登录 : null} ) : ( /* Read-only footer: the same 56px identity block, minus the chevron and the menu. The account menu and its dialogs live on `/`, so this goes there rather than growing a second copy of them. */ {account?.avatar ? : } {showExpandedContent && account ? {account.name} : null} )}
); }