Files
Jyotisha/frontend/src/components/app-sidebar.tsx
T
Jesse_ChenandCursor a31a5e2426
Independent Staging Quality Gate / validate (push) Successful in 9m46s
Independent Staging Quality Gate / publish (push) Successful in 32m4s
fix(rectification): open history cases with their bound skill identity (BUG-621)
Session open was sending the live registry version into v2, so every Skill
bump rejected older cases with an unmapped 500 and no error on the clicked row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 19:35:23 +08:00

368 lines
14 KiB
TypeScript

"use client";
import { Menu } from "@base-ui/react/menu";
import {
ChevronDown,
ChevronRight,
Clock3,
FileText,
LogOut,
Settings,
WalletCards,
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 { 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;
};
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;
/** 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;
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;
onOpenChartLibrary: () => void;
onOpenGeneral: () => void;
onOpenBilling: () => void;
onOpenLogout: () => void;
};
export function AppSidebar({
sessions,
charts,
activeSessionId,
openingSessionId = null,
openErrorSessionId = null,
openErrorMessage = "",
account,
accountMenuOpen,
accountTriggerRef,
newChatDisabled,
creatingSession,
sessionControls,
onAccountMenuOpenChange,
onNewChat,
onOpenReports,
onSelectSession,
onSelectChart,
onAddChart,
onOpenProfile,
onOpenChartLibrary,
onOpenGeneral,
onOpenBilling,
onOpenLogout,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const firstSessionRef = useRef<HTMLButtonElement>(null);
const historyHeadingRef = useRef<HTMLElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(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);
const historyGroups = groupSessionsByRecency(historySessions);
useEffect(() => {
if (!sessionControls.hasMore || !sessionControls.onLoadMore) return;
const node = loadMoreRef.current;
if (!node) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) sessionControls.onLoadMore?.();
});
observer.observe(node);
return () => observer.disconnect();
}, [historySessions.length, sessionControls.hasMore, sessionControls.onLoadMore]);
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}
opening={session.id === openingSessionId}
error={session.id === openErrorSessionId && openErrorMessage ? openErrorMessage : undefined}
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">
{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>
))}
{sessionControls.hasMore ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
</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={onOpenChartLibrary}>
<Users aria-hidden="true" /><span>星盘资料</span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
<Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenBilling}>
<WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} </small>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
<ThemePreferenceMenu />
<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>
);
}