Narrow screens were wrapping 已执行 into a glyph column and painting every sidebar row the same gray, so history looked like top-level chrome. Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Menu } from "@base-ui/react/menu";
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Clock3,
|
|
FileText,
|
|
Gift,
|
|
LogOut,
|
|
MessageSquareText,
|
|
SquarePen,
|
|
Star,
|
|
UserPlus,
|
|
UserRound,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef } from "react";
|
|
import type { Ref } from "react";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarRail,
|
|
useSidebar,
|
|
} from "@/components/ui/sidebar";
|
|
import {
|
|
SidebarSessionRow,
|
|
type SidebarSession,
|
|
type SidebarSessionControls,
|
|
} from "@/components/sidebar-session-row";
|
|
import { UserAvatar } from "@/components/user-avatar";
|
|
import type { BeamAvatar } from "@/lib/beam-avatar";
|
|
|
|
export type SidebarAccount = {
|
|
name: string;
|
|
email: string;
|
|
credits: number;
|
|
initial: string;
|
|
avatar: BeamAvatar | null;
|
|
};
|
|
|
|
export type SidebarChart = {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly role: "self" | "other";
|
|
};
|
|
|
|
export type AppSidebarProps = {
|
|
sessions: readonly SidebarSession[];
|
|
charts: readonly SidebarChart[];
|
|
activeSessionId: string | null;
|
|
account: SidebarAccount;
|
|
accountMenuOpen: boolean;
|
|
accountTriggerRef: Ref<HTMLButtonElement>;
|
|
newChatDisabled: boolean;
|
|
creatingSession: boolean;
|
|
sessionControls: SidebarSessionControls;
|
|
onAccountMenuOpenChange: (open: boolean) => void;
|
|
onNewChat: () => void;
|
|
onOpenReports: () => void;
|
|
onSelectSession: (sessionId: string) => void;
|
|
onSelectChart: (chartId: string) => void;
|
|
onAddChart: () => void;
|
|
onOpenProfile: () => void;
|
|
onOpenRedeem: () => void;
|
|
onOpenLogout: () => void;
|
|
};
|
|
|
|
export function AppSidebar({
|
|
sessions,
|
|
charts,
|
|
activeSessionId,
|
|
account,
|
|
accountMenuOpen,
|
|
accountTriggerRef,
|
|
newChatDisabled,
|
|
creatingSession,
|
|
sessionControls,
|
|
onAccountMenuOpenChange,
|
|
onNewChat,
|
|
onOpenReports,
|
|
onSelectSession,
|
|
onSelectChart,
|
|
onAddChart,
|
|
onOpenProfile,
|
|
onOpenRedeem,
|
|
onOpenLogout,
|
|
}: AppSidebarProps) {
|
|
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
|
|
const firstSessionRef = useRef<HTMLButtonElement>(null);
|
|
const historyHeadingRef = useRef<HTMLElement>(null);
|
|
const isCollapsedDesktop = state === "collapsed" && !isMobile;
|
|
const showExpandedContent = !isCollapsedDesktop;
|
|
const menuPlacement = `${viewport}:${state}`;
|
|
const previousMenuPlacement = useRef(menuPlacement);
|
|
const favoriteSessions = sessions.filter((session) => session.pinned);
|
|
const historySessions = sessions.filter((session) => !session.pinned);
|
|
|
|
useEffect(() => {
|
|
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
|
|
onAccountMenuOpenChange(false);
|
|
}
|
|
previousMenuPlacement.current = menuPlacement;
|
|
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
|
|
|
|
function handleNewChat() {
|
|
onNewChat();
|
|
if (isMobile) setOpenMobile(false);
|
|
}
|
|
|
|
function handleOpenReports() {
|
|
onOpenReports();
|
|
if (isMobile) setOpenMobile(false);
|
|
}
|
|
|
|
function handleExpandHistory() {
|
|
setOpen(true);
|
|
window.requestAnimationFrame(() => {
|
|
(firstSessionRef.current ?? historyHeadingRef.current)?.focus();
|
|
});
|
|
}
|
|
|
|
function renderSession(session: SidebarSession, index: number) {
|
|
return (
|
|
<SidebarMenuItem key={session.id}>
|
|
<SidebarSessionRow
|
|
ref={index === 0 ? firstSessionRef : undefined}
|
|
session={session}
|
|
active={session.id === activeSessionId}
|
|
disabled={sessionControls.disabled}
|
|
menuOpen={sessionControls.menuSessionId === session.id}
|
|
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
|
|
onSelect={() => {
|
|
onSelectSession(session.id);
|
|
if (isMobile) setOpenMobile(false);
|
|
}}
|
|
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)}
|
|
/>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<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>
|
|
</SidebarMenuItem>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
className="report-nav-button"
|
|
type="button"
|
|
tooltip="我的报告"
|
|
onClick={handleOpenReports}
|
|
>
|
|
<FileText size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
{showExpandedContent ? <span>我的报告</span> : null}
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent>
|
|
{showExpandedContent ? (
|
|
<>
|
|
<SidebarGroup className="chart-nav" aria-label="星盘列表">
|
|
<div className="session-nav-header">
|
|
<SidebarGroupLabel className="sidebar-label">
|
|
<Users size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
星盘列表
|
|
</SidebarGroupLabel>
|
|
<button
|
|
className="session-nav-toggle"
|
|
type="button"
|
|
aria-label="添加星盘"
|
|
onClick={onAddChart}
|
|
>
|
|
<UserPlus size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<SidebarGroupContent className="sidebar-nested">
|
|
{charts.length === 0 ? <p className="sidebar-empty">还没有星盘</p> : (
|
|
<div className="chart-nav-list">
|
|
{charts.map((chart) => (
|
|
<button
|
|
className="chart-nav-chip"
|
|
key={chart.id}
|
|
type="button"
|
|
onClick={() => onSelectChart(chart.id)}
|
|
>
|
|
{chart.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<SidebarGroup className="session-nav" aria-label="收藏对话">
|
|
<details className="sidebar-section" open>
|
|
<summary className="sidebar-section-summary">
|
|
<Star size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
<span>收藏对话</span>
|
|
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
|
|
</summary>
|
|
<SidebarGroupContent className="sidebar-nested">
|
|
{favoriteSessions.length === 0 ? <p className="sidebar-empty">还没有收藏</p> : (
|
|
<SidebarMenu className="session-list">
|
|
{favoriteSessions.map((session, index) => renderSession(session, index))}
|
|
</SidebarMenu>
|
|
)}
|
|
</SidebarGroupContent>
|
|
</details>
|
|
</SidebarGroup>
|
|
|
|
<SidebarGroup className="session-nav" aria-label={sessionControls.showingArchived ? "归档记录" : "历史对话"}>
|
|
<details className="sidebar-section" open>
|
|
<summary className="sidebar-section-summary" ref={historyHeadingRef} tabIndex={-1}>
|
|
<Clock3 size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
<span>{sessionControls.showingArchived ? "归档记录" : "历史对话"}</span>
|
|
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
|
|
</summary>
|
|
<SidebarGroupContent className="sidebar-nested">
|
|
{historySessions.length === 0 ? <p className="sidebar-empty">暂无对话</p> : (
|
|
<SidebarMenu className="session-list">
|
|
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
|
|
</SidebarMenu>
|
|
)}
|
|
</SidebarGroupContent>
|
|
</details>
|
|
</SidebarGroup>
|
|
</>
|
|
) : (
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
type="button"
|
|
tooltip="聊天记录"
|
|
aria-label="聊天记录"
|
|
onClick={handleExpandHistory}
|
|
>
|
|
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
)}
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="sidebar-footer">
|
|
<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>
|
|
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
|
|
<UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" />
|
|
</Menu.Item>
|
|
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
|
|
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small>
|
|
</Menu.Item>
|
|
<Menu.Separator className="account-menu-separator" />
|
|
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
|
|
<LogOut aria-hidden="true" /><span>退出登录</span>
|
|
</Menu.Item>
|
|
</Menu.Popup>
|
|
</Menu.Positioner>
|
|
</Menu.Portal>
|
|
</Menu.Root>
|
|
</SidebarFooter>
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|