Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { FeedbackDialog } from "@/components/feedback-dialog";
|
|
import { Menu } from "@base-ui/react/menu";
|
|
import {
|
|
CalendarDays,
|
|
ChevronRight,
|
|
FileText,
|
|
LogOut,
|
|
Orbit,
|
|
Settings,
|
|
WalletCards,
|
|
MessageSquareText,
|
|
MessageSquareWarning,
|
|
SquarePen,
|
|
Star,
|
|
UserRound,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type { ReactNode, Ref } from "react";
|
|
import { AppLink } from "@/components/app-link";
|
|
import { accountDialogHref, type AccountDialogIntent } from "@/lib/account-dialog-intent";
|
|
import { newChatHref, sessionHref } from "@/lib/chat-session-url";
|
|
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
|
|
import { prefetchBillingPanel } from "@/lib/billing-panel-loader";
|
|
import { prefetchPaymentPackages } from "@/lib/payment-packages-cache";
|
|
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";
|
|
import { markNewChatClick } from "@/lib/new-chat-timing";
|
|
|
|
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 settings dialogs behind the
|
|
* account menu. Omit `controls` and the same sidebar renders read-only — rows
|
|
* are links, nothing writes — and the same account menu's dialog entries
|
|
* become links to `/?account=<dialog>` (TASK-self-edit-avatar-menu-20260928 S2).
|
|
*
|
|
* `/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<HTMLButtonElement>;
|
|
newChatDisabled: boolean;
|
|
creatingSession: boolean;
|
|
sessionControls: SidebarSessionControls;
|
|
onAccountMenuOpenChange: (open: boolean) => void;
|
|
onNewChat: () => void;
|
|
onSelectSession: (sessionId: string) => void;
|
|
onOpenProfile: () => 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. */
|
|
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 },
|
|
{ href: "/people", label: "星盘档案", icon: Users },
|
|
] as const;
|
|
|
|
export function AppSidebar({
|
|
sessions,
|
|
activeSessionId,
|
|
openingSessionId = null,
|
|
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<HTMLButtonElement>(null);
|
|
const historyHeadingRef = useRef<HTMLParagraphElement>(null);
|
|
const loadMoreRef = useRef<HTMLDivElement>(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: controlledMenuOpen = false,
|
|
accountTriggerRef,
|
|
newChatDisabled = false,
|
|
creatingSession = false,
|
|
sessionControls,
|
|
onAccountMenuOpenChange: controlledMenuOpenChange,
|
|
onNewChat,
|
|
onSelectSession,
|
|
onOpenProfile,
|
|
onOpenGeneral,
|
|
onOpenBilling,
|
|
onOpenLogout,
|
|
}: Partial<AppSidebarControls> = controls ?? {};
|
|
/* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */
|
|
const [ownMenuOpen, setOwnMenuOpen] = useState(false);
|
|
/* 反馈与投诉 is the sidebar's own dialog, so it opens the same way on every page. */
|
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
|
const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen;
|
|
const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen;
|
|
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();
|
|
}
|
|
|
|
/** Off `/`: the same drawer close, plus the click mark for new-chat timing (BUG-1104). */
|
|
function handleNewChatLink() {
|
|
markNewChatClick();
|
|
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. Nothing is stashed on the way out: a bare
|
|
`/` lands on the blank starter home, and the way back to a conversation
|
|
is its `?c=` link (BUG-1052). */
|
|
function leaveChat() {
|
|
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,
|
|
};
|
|
return (
|
|
<SidebarMenuItem key={session.id}>
|
|
{sessionControls && onSelectSession ? (
|
|
<SidebarSessionRow
|
|
{...shared}
|
|
ref={index === 0 ? firstSessionRef : undefined}
|
|
disabled={sessionControls.disabled}
|
|
menuOpen={sessionControls.menuSessionId === session.id}
|
|
onMenuOpenChange={(open) => 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)}
|
|
onDelete={() => sessionControls.onDelete(session.id)}
|
|
/>
|
|
) : (
|
|
<SidebarSessionRow {...shared} href={sessionHref("", session.id)} onNavigate={closeDrawer} />
|
|
)}
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
/* One entry of the account menu: on `/` the dialog opens in place; on
|
|
another page the entry is a link to `/?account=<dialog>` (S2). */
|
|
function accountEntry(
|
|
dialog: AccountDialogIntent,
|
|
onOpen: (() => void) | undefined,
|
|
className: string,
|
|
content: ReactNode,
|
|
pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {},
|
|
) {
|
|
return onOpen ? (
|
|
<Menu.Item className={className} onClick={onOpen} {...pointer}>{content}</Menu.Item>
|
|
) : (
|
|
<Menu.LinkItem
|
|
className={className}
|
|
closeOnClick
|
|
render={<AppLink href={accountDialogHref(dialog)} onClick={leaveChat} />}
|
|
{...pointer}
|
|
>
|
|
{content}
|
|
</Menu.LinkItem>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Sidebar className="sidebar" aria-label="对话导航">
|
|
<SidebarHeader className="sidebar-header">
|
|
<div className="brand-row">
|
|
<span className="brand-mark" aria-hidden="true" />
|
|
{showExpandedContent ? <strong>Jyotisha</strong> : null}
|
|
</div>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
{controls ? (
|
|
<SidebarMenuButton
|
|
className="new-chat"
|
|
type="button"
|
|
tooltip="新对话"
|
|
disabled={newChatDisabled}
|
|
onClick={handleNewChat}
|
|
>
|
|
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
|
|
</SidebarMenuButton>
|
|
) : (
|
|
<SidebarMenuLink className="new-chat" tooltip="新对话" href={newChatHref()} onClick={handleNewChatLink}>
|
|
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
{showExpandedContent ? <span>新建对话</span> : null}
|
|
</SidebarMenuLink>
|
|
)}
|
|
</SidebarMenuItem>
|
|
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
|
|
<SidebarMenuItem key={href}>
|
|
<SidebarMenuLink
|
|
className="report-nav-button"
|
|
tooltip={label}
|
|
isActive={pathname === href || pathname.startsWith(`${href}/`)}
|
|
href={href}
|
|
onPointerEnter={() => prefetchSecondaryPage(href)}
|
|
onPointerDown={() => prefetchSecondaryPage(href)}
|
|
onClick={leaveChat}
|
|
>
|
|
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
{showExpandedContent ? <span>{label}</span> : null}
|
|
</SidebarMenuLink>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent>
|
|
{showExpandedContent ? (
|
|
<SidebarGroup className="session-nav" aria-label="最近对话">
|
|
{/* One flat list. It was three stacked sections — a 星盘列表 chip grid
|
|
and two <details> 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. */}
|
|
<p className="sidebar-section-label" ref={historyHeadingRef} tabIndex={-1}>
|
|
最近
|
|
</p>
|
|
<SidebarGroupContent className="sidebar-nested">
|
|
{favoriteSessions.length === 0 && historySessions.length === 0 ? (
|
|
/* Static copy while a read-only list is in flight — never a skeleton. */
|
|
<p className="sidebar-empty">
|
|
{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}
|
|
</p>
|
|
) : (
|
|
<SidebarMenu className="session-list">
|
|
{favoriteSessions.length > 0 ? (
|
|
<div>
|
|
<p className="sidebar-group-label">
|
|
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
|
|
收藏
|
|
</p>
|
|
{favoriteSessions.map((session, index) => renderSession(session, index))}
|
|
</div>
|
|
) : null}
|
|
{historyGroups.map((group) => (
|
|
<div key={group.key}>
|
|
<p className="sidebar-group-label">{group.label}</p>
|
|
{group.sessions.map((session) => renderSession(
|
|
session,
|
|
favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id),
|
|
))}
|
|
</div>
|
|
))}
|
|
{hasMoreSessions ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
|
|
</SidebarMenu>
|
|
)}
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
) : (
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
type="button"
|
|
tooltip="聊天记录"
|
|
aria-label="聊天记录"
|
|
onClick={handleExpandHistory}
|
|
>
|
|
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
)}
|
|
</SidebarContent>
|
|
|
|
{feedbackOpen && account ? (
|
|
<FeedbackDialog accountEmail={account.email} sessionId={activeSessionId} onClose={() => setFeedbackOpen(false)} />
|
|
) : null}
|
|
<SidebarFooter className="sidebar-footer">
|
|
{account ? (
|
|
/* One account menu on every page. On `/` Home controls it and its
|
|
entries open the dialogs in place; elsewhere it keeps its own open
|
|
state and the dialog entries go to `/?account=<dialog>`, where Home
|
|
opens that dialog (S2). Theme applies where the reader is. */
|
|
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
|
|
<Menu.Trigger
|
|
className="profile-trigger"
|
|
ref={accountTriggerRef}
|
|
type="button"
|
|
>
|
|
{account.avatar
|
|
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
|
|
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
|
|
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
|
|
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
|
|
</Menu.Trigger>
|
|
<Menu.Portal>
|
|
<Menu.Positioner
|
|
side={isMobile || state === "expanded" ? "top" : "right"}
|
|
align={isMobile || state === "expanded" ? "end" : "center"}
|
|
sideOffset={8}
|
|
collisionPadding={12}
|
|
>
|
|
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
|
|
<div className="account-menu-identity">
|
|
{account.avatar
|
|
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
|
|
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
|
|
<span><b>{account.name}</b><small>{account.email}</small></span>
|
|
</div>
|
|
{accountEntry("profile", onOpenProfile, "account-menu-item", (
|
|
<><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></>
|
|
))}
|
|
{accountEntry("general", onOpenGeneral, "account-menu-item", (
|
|
<><Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" /></>
|
|
))}
|
|
{accountEntry("billing", onOpenBilling, "account-menu-item", (
|
|
<><WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small></>
|
|
), {
|
|
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
|
|
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
|
|
})}
|
|
<Menu.Item className="account-menu-item" onClick={() => { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}>
|
|
<MessageSquareWarning aria-hidden="true" /><span>反馈与投诉</span>
|
|
</Menu.Item>
|
|
<Menu.Separator className="account-menu-separator" />
|
|
<ThemePreferenceMenu />
|
|
<Menu.Separator className="account-menu-separator" />
|
|
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
|
|
<><LogOut aria-hidden="true" /><span>退出登录</span></>
|
|
))}
|
|
{/* The documents the user agreed to, one tap away (2026-09-30). */}
|
|
<div className="account-menu-legal">
|
|
<Menu.LinkItem href="/terms" target="_blank" rel="noopener">用户协议</Menu.LinkItem>
|
|
<span aria-hidden="true">·</span>
|
|
<Menu.LinkItem href="/privacy" target="_blank" rel="noopener">隐私政策</Menu.LinkItem>
|
|
</div>
|
|
</Menu.Popup>
|
|
</Menu.Positioner>
|
|
</Menu.Portal>
|
|
</Menu.Root>
|
|
) : signedOut ? (
|
|
/* `/login` crosses an auth boundary, so it stays a document load. */
|
|
<a className="profile-trigger" href="/login" onClick={leaveChat}>
|
|
<span className="profile-initial" aria-hidden="true">·</span>
|
|
{showExpandedContent ? <span><b>去登录</b></span> : null}
|
|
</a>
|
|
) : (
|
|
/* The account is still being read: the same 56px block, inert, with
|
|
no link and no spinner. It becomes the menu as soon as it lands. */
|
|
<div className="profile-trigger" aria-hidden="true">
|
|
<span className="profile-initial">·</span>
|
|
</div>
|
|
)}
|
|
</SidebarFooter>
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|