refactor(ui): 侧栏统一成一个组件,次级页共享外壳,跳转不再整页刷新

BUG-744 / BUG-745 / BUG-746,任务书 TASK-sidebar-unify-20260916。

T1 侧栏只剩一个组件。`AppSidebar` 收编次级页那份另写的侧栏:会话操作与
账户菜单收进可选的 `controls`,不传就渲染只读模式。只读行仍是同一个
`SidebarSessionRow`、同一套 `.session-row > .session-main` 标记,只是
`.session-main` 是 `<Link>`、不渲染菜单按钮,并用 `data-readonly="true"`
去掉那一列从不使用的 44px 空位;页脚是同一个 56px `.profile-trigger`,
渲染成去 `/` 的链接。只读模式只少菜单按钮、chevron、账户菜单三样。
`app-nav-rail.tsx`、`use-nav-rail.ts` 与 `.nav-rail-*` 两段 CSS 删除。

T2 四个次级路由移进 `app/(secondary)/` 路由组,`layout.tsx` 承载
`SidebarProvider + AppSidebar(只读) + SidebarInset`。`SecondaryShell` 拆剩
46px 顶栏并改名 `SecondaryHeader`,14 处调用同步。路由组不进 URL,四个
地址与四个渲染标记均未变。

T3 `sidebar-data-cache.ts`:模块级、按账户 id 键、60 秒的内存缓存,同步读
再后台刷新,不落 localStorage。`use-session-management.ts` 的新建 / 重命名 /
删除 / 归档 / 收藏成功后失效,401 清空。

T4 三个页面项改 `<SidebarMenuLink href=…>` 客户端跳转,`persistLoginSessionReturn()`
保留在 `onClick` 里,`/login` 仍是硬跳转。顺带删掉从未被调用的死 prop
`onOpenReports`;它删掉后 `page.tsx` 的 `router` 再无消费者,`useConsultationRun`
里同样解构成 `_router` 的死参数一并删。

T5 折叠状态存 localStorage 的 `sidebar_state`(不用 cookie:`/`、`/chart`、
`/ephemeris` 都是 Static,服务端读 cookie 会让三条路由掉出静态渲染)。移动端
抽屉不记。整页加载首帧仍可能闪一下,属让步顺序第 1 条,写在 BUG-746 与真机清单。

`Home()` 的 useState 36 / useRef 37 均未增长,`page.tsx` 净删 1 行。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 11:23:11 +00:00
co-authored by Claude Fable 5.1
parent 302ff08504
commit d9d347236f
27 changed files with 835 additions and 676 deletions
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { ChartPageRoute } from "@/components/chart-page/chart-page-view";
import "../site-styles";
export const metadata: Metadata = {
title: "星盘 · Jyotisha",
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { EphemerisPage } from "@/components/ephemeris/ephemeris-page";
import "../site-styles";
export const metadata: Metadata = {
title: "星历 · Jyotisha",
+45
View File
@@ -0,0 +1,45 @@
"use client";
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import "../site-styles";
/**
* One shell for `/chart`, `/ephemeris`, `/reports` and `/reports/[reportId]`.
*
* A route group changes no URL: the four routes keep their paths and their own
* `metadata` and `dynamic` declarations. What it changes is who owns the shell.
* Each page used to render `SecondaryShell`, so each carried a provider and a
* sidebar of its own; stepping between two of them tore the nav down and built
* it again, list request included. Hoisting it here means the sidebar is mounted
* once per visit to this section, and `useSidebarData` runs once with it.
*
* The sidebar is read-only on purpose — no renaming, archiving, deleting or
* account menu. Those are backed by `Home()`'s optimistic-update and rollback
* layer, and none of these four pages offers session management
* (TASK-cend-surfaces-claude-alignment-20260916 D9, carried forward by
* TASK-sidebar-unify-20260916 D1).
*/
export default function SecondaryLayout({ children }: { children: ReactNode }) {
const { sessions, account, settled, signedOut } = useSidebarData();
return (
<SidebarProvider>
<main className="chat-app">
<AppSidebar
sessions={sessions}
activeSessionId={null}
account={account}
settled={settled}
signedOut={signedOut}
/>
<SidebarInset className="chat-panel secondary-panel">
{children}
</SidebarInset>
</main>
</SidebarProvider>
);
}
@@ -5,7 +5,7 @@ import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import "../../site-styles";
import "../../../site-styles";
export default function ReportError({
error,
@@ -1,5 +1,5 @@
import { InlineSpinner } from "@/components/inline-spinner";
import "../../site-styles";
import "../../../site-styles";
export default function ReportLoading() {
return (
@@ -3,7 +3,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import "../../site-styles";
import "../../../site-styles";
export default function ReportNotFound() {
return (
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
import "../../site-styles";
export const dynamic = "force-dynamic";
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
import "../site-styles";
export const dynamic = "force-dynamic";
+8 -34
View File
@@ -707,8 +707,8 @@ button:disabled { cursor: default; opacity: .45; }
.brand-row strong { font-weight: 400; }
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-decoration: none; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.report-nav-button[data-active="true"] {
position: relative;
@@ -769,9 +769,13 @@ button:disabled { cursor: default; opacity: .45; }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-lg); color: var(--sidebar-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; }
.session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); }
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; }
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); }
/* Read-only rows (the secondary pages) render no menu trigger, so the 44px
column the interactive row reserves for one would be dead space at the end
of every title. Everything else about the row is the same markup. */
.session-row[data-readonly="true"] { grid-template-columns: minmax(0, 1fr); }
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; }
.session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; }
/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */
@@ -813,7 +817,7 @@ button:disabled { cursor: default; opacity: .45; }
.session-actions .session-action-danger, .session-actions .session-action-danger > svg { color: var(--color-danger); }
.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); }
.sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); }
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-2); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
.user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); }
.user-avatar > svg { width: 100%; height: 100%; display: block; }
.profile-avatar { width: 32px; height: 32px; }
@@ -2023,36 +2027,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-weight: 500;
}
/* The rail is the chat sidebar minus every write action. It reuses the sidebar
surface and row styles; only the identity block differs, because there is no
account menu to open from here. */
.nav-rail-row { width: 100%; }
.nav-rail-identity {
min-height: 44px;
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--sidebar-foreground);
font-size: var(--type-body-sm);
text-align: left;
text-decoration: none;
}
.nav-rail-identity > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-rail-identity > small {
margin-left: auto;
flex: 0 0 auto;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-variant-numeric: tabular-nums;
}
button.nav-rail-identity { cursor: pointer; }
button.nav-rail-identity:hover { background: var(--sidebar-accent); }
@media (max-width: 767px) {
/* Replaces the three `*-shell { padding-inline }` rules the standalone pages
each carried. Same global 767px cut, one rule instead of three. */
+34 -36
View File
@@ -3,7 +3,6 @@
import "@/app/site-styles";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
@@ -239,7 +238,6 @@ const BillingPanel = dynamic(
);
export default function Home() {
const router = useRouter();
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
@@ -739,7 +737,6 @@ export default function Home() {
pendingConsultation,
pendingSessionId,
profile,
router,
sessions,
setAccount,
setActiveSessionId,
@@ -1552,41 +1549,42 @@ export default function Home() {
openErrorSessionId={rectificationErrorSessionId}
openErrorMessage={rectificationErrorSessionId ? rectificationErrorMessage : ""}
account={sidebarAccount}
accountMenuOpen={accountMenuOpen}
accountTriggerRef={accountTrigger}
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
sessionControls={{
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onToggleArchived: toggleArchivedSession,
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
controls={{
newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending,
creatingSession,
accountMenuOpen,
accountTriggerRef: accountTrigger,
sessionControls: {
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onToggleArchived: toggleArchivedSession,
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
},
},
onAccountMenuOpenChange: setAccountMenuOpen,
onNewChat: () => void startNewChat(),
onSelectSession: selectSession,
onOpenProfile: () => openAccountDialog("profile"),
onOpenChartLibrary: () => openAccountDialog("chart-library"),
onOpenGeneral: () => openAccountDialog("general"),
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
onOpenLogout: () => openAccountDialog("logout"),
}}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onOpenReports={() => router.push("/reports")}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenChartLibrary={() => openAccountDialog("chart-library")}
onOpenGeneral={() => openAccountDialog("general")}
onOpenBilling={() => openAccountDialog("billing", { source: "account-menu" })}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
-174
View File
@@ -1,174 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import { groupSessionsByRecency } from "@/lib/session-groups";
import { UserAvatar } from "@/components/user-avatar";
import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail";
/**
* The nav that /chart, /ephemeris and /reports carry.
*
* Those three pages used to be standalone full-screen routes whose only way back
* was a 「返回对话」 link: the sidebar disappeared entirely the moment you opened
* a chart. This rail keeps it on screen, at the cost of being read-only —
* renaming, pinning, archiving and deleting stay on `/`, where the state that
* backs them lives. See TASK-cend-surfaces-claude-alignment-20260916 D9.
*/
const NAV_PAGES = [
{ href: "/chart", label: "星盘", icon: Orbit },
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
{ href: "/reports", label: "我的报告", icon: FileText },
] as const;
export function AppNavRail() {
const { sessions, account, settled, signedOut } = useNavRail();
const { isMobile, setOpenMobile, state } = 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 showExpandedContent = !(state === "collapsed" && !isMobile);
const pinned = sessions.filter((session) => session.pinned);
const history = sessions.filter((session) => !session.pinned);
const groups = groupSessionsByRecency(history);
/* Real links, not buttons with a router call. `useRouter()` throws outside an
app-router context (it took out twelve render tests), and an <a href> gives
client-side navigation for free — clicking 新建对话 from a chart used to
reload the whole document. `/login` keeps the hard exit: it crosses an auth
boundary and wants the return target persisted first. */
function leaveForLogin() {
persistLoginSessionReturn();
if (isMobile) setOpenMobile(false);
}
function closeDrawer() {
if (isMobile) setOpenMobile(false);
}
function renderRow(session: NavRailSession) {
return (
<SidebarMenuItem key={session.id}>
<Link className="session-row nav-rail-row" href={sessionHref("", session.id)} onClick={closeDrawer}>
<span className="session-title">{session.title}</span>
</Link>
</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>
<Link className="new-chat" data-sidebar="menu-button" href="/" title="新对话" onClick={closeDrawer}>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>新建对话</span> : null}
</Link>
</SidebarMenuItem>
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
<SidebarMenuItem key={href}>
<Link
className="report-nav-button"
data-sidebar="menu-button"
data-active={pathname === href || pathname.startsWith(`${href}/`)}
href={href}
title={label}
onClick={closeDrawer}
>
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{showExpandedContent ? (
<SidebarGroup className="session-nav" aria-label="最近对话">
<p className="sidebar-section-label">最近</p>
<SidebarGroupContent className="sidebar-nested">
{/* Static copy while the list is in flight — never a skeleton. */}
{sessions.length === 0 ? (
<p className="sidebar-empty">
{signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"}
</p>
) : (
<SidebarMenu className="session-list">
{pinned.length > 0 ? (
<div>
<p className="sidebar-group-label">
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
收藏
</p>
{pinned.map(renderRow)}
</div>
) : null}
{groups.map((group) => (
<div key={group.key}>
<p className="sidebar-group-label">{group.label}</p>
{group.sessions.map(renderRow)}
</div>
))}
</SidebarMenu>
)}
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter className="sidebar-footer">
{signedOut ? (
<a className="nav-rail-identity" href="/login" onClick={leaveForLogin}>
<span className="profile-initial" aria-hidden="true">·</span>
{showExpandedContent ? <span><b>去登录</b></span> : null}
</a>
) : (
/* Draws the same beam avatar the chat sidebar does — the rail used to
show only the initial, so the same account wore two different faces
depending on which page you were on. It is a real control now: the
account menu itself lives on `/` with the settings dialogs behind
it, so this goes there rather than growing a second copy. */
<Link
className="nav-rail-identity"
href="/"
aria-label={account ? `${account.name},余额 ${account.credits} 点,打开账户` : "打开账户"}
onClick={closeDrawer}
>
{account?.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
{showExpandedContent && account ? (
<>
<span><b>{account.name}</b></span>
<small>{account.credits} 点</small>
</>
) : null}
</Link>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
+217 -164
View File
@@ -15,10 +15,11 @@ import {
UserRound,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import { persistLoginSessionReturn } from "@/lib/chat-session-url";
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
import {
Sidebar,
SidebarContent,
@@ -29,6 +30,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuLink,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
@@ -50,15 +52,21 @@ export type SidebarAccount = {
avatar: BeamAvatar | null;
};
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;
/**
* 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<HTMLButtonElement>;
newChatDisabled: boolean;
@@ -66,7 +74,6 @@ export type AppSidebarProps = {
sessionControls: SidebarSessionControls;
onAccountMenuOpenChange: (open: boolean) => void;
onNewChat: () => void;
onOpenReports: () => void;
onSelectSession: (sessionId: string) => void;
onOpenProfile: () => void;
onOpenChartLibrary: () => void;
@@ -75,6 +82,28 @@ export type AppSidebarProps = {
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,
@@ -82,23 +111,14 @@ export function AppSidebar({
openErrorSessionId = null,
openErrorMessage = "",
account,
accountMenuOpen,
accountTriggerRef,
newChatDisabled,
creatingSession,
sessionControls,
onAccountMenuOpenChange,
onNewChat,
onOpenReports: _onOpenReports,
onSelectSession,
onOpenProfile,
onOpenChartLibrary,
onOpenGeneral,
onOpenBilling,
onOpenLogout,
settled = true,
signedOut = false,
controls,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const pathname = usePathname();
/* 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);
@@ -106,51 +126,65 @@ export function AppSidebar({
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<AppSidebarControls> = 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 (!sessionControls.hasMore || !sessionControls.onLoadMore) return;
if (!hasMoreSessions || !onLoadMoreSessions) return;
const node = loadMoreRef.current;
if (!node) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) sessionControls.onLoadMore?.();
if (entries.some((entry) => entry.isIntersecting)) onLoadMoreSessions();
});
observer.observe(node);
return () => observer.disconnect();
}, [historySessions.length, sessionControls.hasMore, sessionControls.onLoadMore]);
}, [historySessions.length, hasMoreSessions, onLoadMoreSessions]);
useEffect(() => {
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
onAccountMenuOpenChange(false);
onAccountMenuOpenChange?.(false);
}
previousMenuPlacement.current = menuPlacement;
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
function closeDrawer() {
if (isMobile) setOpenMobile(false);
}
function handleNewChat() {
onNewChat();
if (isMobile) setOpenMobile(false);
onNewChat?.();
closeDrawer();
}
function leaveChat(path: "/chart" | "/ephemeris" | "/reports") {
/* 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();
window.location.assign(path);
}
function handleOpenReports() {
leaveChat("/reports");
if (isMobile) setOpenMobile(false);
}
function handleOpenChart() {
leaveChat("/chart");
if (isMobile) setOpenMobile(false);
}
function handleOpenEphemeris() {
leaveChat("/ephemeris");
if (isMobile) setOpenMobile(false);
closeDrawer();
}
function handleExpandHistory() {
@@ -161,27 +195,34 @@ export function AppSidebar({
}
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 (
<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)}
/>
{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)}
onToggleArchived={() => sessionControls.onToggleArchived(session.id)}
onDelete={() => sessionControls.onDelete(session.id)}
/>
) : (
<SidebarSessionRow {...shared} href={sessionHref("", session.id)} onNavigate={closeDrawer} />
)}
</SidebarMenuItem>
);
}
@@ -195,69 +236,58 @@ export function AppSidebar({
</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="星盘"
isActive={pathname === "/chart"}
onClick={handleOpenChart}
>
<Orbit size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>星盘</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip="星历"
isActive={pathname === "/ephemeris" || pathname.startsWith("/ephemeris/")}
onClick={handleOpenEphemeris}
>
<CalendarDays size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>星历</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>
{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="/" onClick={closeDrawer}>
<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}
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={sessionControls.showingArchived ? "归档记录" : "最近对话"}>
<SidebarGroup className="session-nav" aria-label={sessionControls?.showingArchived ? "归档记录" : "最近对话"}>
{/* 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}>
{sessionControls.showingArchived ? "归档记录" : "最近"}
{sessionControls?.showingArchived ? "归档记录" : "最近"}
</p>
<SidebarGroupContent className="sidebar-nested">
{favoriteSessions.length === 0 && historySessions.length === 0 ? (
<p className="sidebar-empty">暂无对话,点上方「新建对话」开始</p>
/* 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 ? (
@@ -278,7 +308,7 @@ export function AppSidebar({
))}
</div>
))}
{sessionControls.hasMore ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
{hasMoreSessions ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
</SidebarMenu>
)}
</SidebarGroupContent>
@@ -300,54 +330,77 @@ export function AppSidebar({
</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}
{controls && account ? (
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
<Menu.Trigger
className="profile-trigger"
ref={accountTriggerRef}
type="button"
>
<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>
{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>
) : 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>
) : (
/* 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. */
<Link
className="profile-trigger"
href="/"
aria-label={account ? `${account.name},打开账户` : "打开账户"}
onClick={closeDrawer}
>
{account?.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
{showExpandedContent && account ? <span><b>{account.name}</b></span> : null}
</Link>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
@@ -2,7 +2,7 @@
import { useState } from "react";
import { SecondaryShell } from "@/components/secondary-shell";
import { SecondaryHeader } from "@/components/secondary-header";
import { useChartPage } from "@/hooks/use-chart-page";
import {
CHART_VIEW_TABS,
@@ -63,7 +63,8 @@ export function ChartPageView({
: null;
return (
<SecondaryShell title="星盘" note={birthline}>
<>
<SecondaryHeader title="星盘" note={birthline} />
{view == null ? (
<section className="chart-page-message">
<p>{CHART_VIEW_COPY.waitingChart}</p>
@@ -122,6 +123,6 @@ export function ChartPageView({
</section>
</div>
)}
</SecondaryShell>
</>
);
}
@@ -8,7 +8,7 @@ import { zhCN } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SecondaryShell } from "@/components/secondary-shell";
import { SecondaryHeader } from "@/components/secondary-header";
import { setComposerDraft } from "@/lib/composer-draft";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
import {
@@ -65,13 +65,14 @@ export function EphemerisPage() {
if (state.phase === "unauthorized") {
return (
<SecondaryShell title={EPHEMERIS_COPY.title}>
<>
<SecondaryHeader title={EPHEMERIS_COPY.title} />
<section className="ephemeris-message">
<h2>{EPHEMERIS_COPY.loginTitle}</h2>
<p>{EPHEMERIS_COPY.loginBody}</p>
<Button render={<Link href="/login" />} nativeButton={false}>{EPHEMERIS_COPY.loginAction}</Button>
</section>
</SecondaryShell>
</>
);
}
@@ -126,14 +127,15 @@ export function EphemerisView(props: {
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
return (
<SecondaryShell
title={EPHEMERIS_COPY.title}
actions={(
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
{EPHEMERIS_COPY.ask}
</Button>
)}
>
<>
<SecondaryHeader
title={EPHEMERIS_COPY.title}
actions={(
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
{EPHEMERIS_COPY.ask}
</Button>
)}
/>
<div className="ephemeris-body">
<section className="ephemeris-hero">
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles.
@@ -276,6 +278,6 @@ export function EphemerisView(props: {
under the page now, and the 「带这天去提问」 action lives in the header. */}
<p className="ephemeris-outro">{EPHEMERIS_COPY.footerNote}</p>
</div>
</SecondaryShell>
</>
);
}
@@ -5,7 +5,7 @@ import { Clock3, FileText, RefreshCw } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SecondaryShell } from "@/components/secondary-shell";
import { SecondaryHeader } from "@/components/secondary-header";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -171,22 +171,24 @@ export function PersonalReportCenter() {
if (state.phase === "unauthorized") {
return (
<SecondaryShell title="我的报告">
<>
<SecondaryHeader title="我的报告" />
<section className="report-center-message">
<FileText aria-hidden="true" className="size-8 text-primary" />
<h2>请先登录</h2>
<p>登录后即可生成和查看你的个人报告。</p>
<Button render={<Link href="/login" />} nativeButton={false}>去登录</Button>
</section>
</SecondaryShell>
</>
);
}
return (
<SecondaryShell
title="我的报告"
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
>
<>
<SecondaryHeader
title="我的报告"
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
/>
<div className="report-center-body">
<section className="report-center-hero">
<div>
@@ -278,6 +280,6 @@ export function PersonalReportCenter() {
)}
</section>
</div>
</SecondaryShell>
</>
);
}
@@ -17,7 +17,7 @@ import { Clock3, TriangleAlert } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryShell } from "@/components/secondary-shell";
import { SecondaryHeader } from "@/components/secondary-header";
import { ReportActions } from "./report-actions";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
@@ -316,7 +316,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
: null;
const writing = progress?.stage === "writing";
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
@@ -331,13 +332,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
</>
)}
</div>
</SecondaryShell>
</>
);
}
if (state.phase === "timed-out") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<Clock3 aria-hidden="true" className="size-8 text-ink-secondary" />
<h1>生成时间超出预期</h1>
@@ -352,13 +354,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
</Button>
</div>
</div>
</SecondaryShell>
</>
);
}
if (state.phase === "unauthorized") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<h1>请先登录</h1>
<p>个人报告仅对登录用户开放。请登录后重试。</p>
@@ -366,13 +369,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
去登录
</Button>
</div>
</SecondaryShell>
</>
);
}
if (state.phase === "not-found") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<h1>报告不存在</h1>
<p>该报告不存在、已删除,或不属于当前账号。</p>
@@ -380,13 +384,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
返回报告中心
</Button>
</div>
</SecondaryShell>
</>
);
}
if (state.phase === "failed") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1>报告生成失败</h1>
@@ -398,13 +403,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
返回报告中心重新生成
</Button>
</div>
</SecondaryShell>
</>
);
}
if (state.phase === "invalid" || state.phase === "network-error") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1>报告暂时无法显示</h1>
@@ -417,13 +423,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
重新加载
</Button>
</div>
</SecondaryShell>
</>
);
}
if (state.phase === "legacy-unavailable") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1>旧版本报告</h1>
@@ -432,13 +439,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
返回报告中心重新生成
</Button>
</div>
</SecondaryShell>
</>
);
}
if (state.phase !== "markdown-ready") {
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-state">
<TriangleAlert aria-hidden="true" className="size-8 text-danger" />
<h1>报告暂时无法显示</h1>
@@ -447,12 +455,13 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
重新加载
</Button>
</div>
</SecondaryShell>
</>
);
}
return (
<SecondaryShell title="个人报告">
<>
<SecondaryHeader title="个人报告" />
<div className="personal-report-reader">
<style media="print">{"@page { size: A4; margin: 13mm 12mm 14mm; }"}</style>
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
@@ -466,6 +475,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
<PersonalReportMarkdownView markdown={state.markdown} />
</div>
</div>
</SecondaryShell>
</>
);
}
@@ -0,0 +1,41 @@
"use client";
import type { ReactNode } from "react";
import { SidebarTrigger } from "@/components/ui/sidebar";
/**
* The 46px header every secondary page carries.
*
* It used to be `SecondaryShell`, which also mounted a `SidebarProvider` and a
* sidebar of its own. Four routes rendering that shell meant four shells: every
* arrival remounted the nav and re-issued `GET /api/sessions` and
* `GET /api/account`, even when the reader had only stepped from `/chart` to
* `/ephemeris`. The provider, the sidebar and the inset now live once, in
* `app/(secondary)/layout.tsx`; what is left here is the header itself, which is
* genuinely per page because only the page knows its name and its actions.
*
* The trigger still sits in this row — the provider is above it in the tree, so
* `useSidebar()` resolves exactly as before.
*/
export type SecondaryHeaderProps = {
/** Shown in the header. Pass a falsy value for pages that carry their own. */
readonly title: string;
/** Right-aligned header controls: the one primary action, at most two. */
readonly actions?: ReactNode;
/** A quiet chip beside the title — birth line, report date. */
readonly note?: ReactNode;
};
export function SecondaryHeader({ title, actions, note }: SecondaryHeaderProps) {
return (
<header className="chat-header has-title">
<SidebarTrigger placement="inset" />
<div className="chat-header-title">
<strong>{title}</strong>
{note ? <span className="chat-header-chart">{note}</span> : null}
</div>
{actions ? <div className="chat-header-actions">{actions}</div> : null}
</header>
);
}
@@ -1,48 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { AppNavRail } from "@/components/app-nav-rail";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
/**
* The app shell for /chart, /ephemeris and /reports.
*
* Each of those pages used to own a full-screen layout of its own — a
* `*-shell` root, a `*-topbar` holding one 「返回对话」 link, and a `*-hero` with
* a page-sized h1. Three copies of the same skeleton, and none of them had the
* sidebar, so opening a chart dropped the reader out of the app entirely.
*
* The page name now sits in the 46px header beside the sidebar trigger, which is
* where the chat page keeps its session title.
*/
export type SecondaryShellProps = {
/** Shown in the header. Pass a falsy value for pages that carry their own. */
readonly title: string;
/** Right-aligned header controls: the one primary action, at most two. */
readonly actions?: ReactNode;
/** A quiet chip beside the title — birth line, report date. */
readonly note?: ReactNode;
readonly children: ReactNode;
};
export function SecondaryShell({ title, actions, note, children }: SecondaryShellProps) {
return (
<SidebarProvider>
<main className="chat-app">
<AppNavRail />
<SidebarInset className="chat-panel secondary-panel">
<header className="chat-header has-title">
<SidebarTrigger placement="inset" />
<div className="chat-header-title">
<strong>{title}</strong>
{note ? <span className="chat-header-chart">{note}</span> : null}
</div>
{actions ? <div className="chat-header-actions">{actions}</div> : null}
</header>
{children}
</SidebarInset>
</main>
</SidebarProvider>
);
}
+69 -22
View File
@@ -11,6 +11,7 @@ import {
StarOff,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { forwardRef } from "react";
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
import { SidebarMenuButton } from "@/components/ui/sidebar";
@@ -41,13 +42,17 @@ export type SidebarSessionControls = {
readonly onDelete: (sessionId: string) => void;
};
type SidebarSessionRowProps = {
type SidebarSessionRowCommonProps = {
readonly session: SidebarSession;
readonly active: boolean;
/** Its Case is being opened: a static note, no spinner. */
readonly opening?: boolean;
/** Open failed for this row: one line under the title, same notice scale as composer. */
readonly error?: string;
};
type SidebarSessionRowInteractiveProps = SidebarSessionRowCommonProps & {
readonly href?: undefined;
readonly disabled: boolean;
readonly menuOpen: boolean;
readonly onMenuOpenChange: (open: boolean) => void;
@@ -59,21 +64,68 @@ type SidebarSessionRowProps = {
readonly onDelete: () => void;
};
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
session,
active,
opening = false,
error,
disabled,
menuOpen,
onMenuOpenChange,
onSelect,
onTogglePinned,
onRename,
onShare,
onToggleArchived,
onDelete,
}, ref) {
/**
* Read-only mode: the row is a plain link to `/?c=<id>` and nothing else.
*
* Same `.session-row > .session-main` markup as the interactive row, because a
* second set of markup is exactly how the secondary pages ended up with rows
* that had no 44px minimum, no padding and no current-item marker while still
* reserving a 44px column for a menu button that was never rendered.
*/
type SidebarSessionRowLinkProps = SidebarSessionRowCommonProps & {
readonly href: string;
readonly onNavigate?: () => void;
};
type SidebarSessionRowProps = SidebarSessionRowInteractiveProps | SidebarSessionRowLinkProps;
function SessionRowBody({ session, opening }: { session: SidebarSession; opening: boolean }) {
return (
<>
<span className="session-title">
{session.pinned ? <Star aria-label="已收藏" /> : null}
<span className="truncate">{session.title}</span>
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
</span>
{session.subtitle ? <small className="session-subtitle">{session.subtitle}</small> : null}
</>
);
}
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow(props, ref) {
const { session, active, opening = false, error } = props;
if (props.href !== undefined) {
return (
<div className="session-row" data-readonly="true">
<Link
className="session-main"
data-sidebar="menu-button"
data-slot="sidebar-menu-button"
data-active={active}
aria-current={active ? "page" : undefined}
href={props.href}
onClick={props.onNavigate}
>
<SessionRowBody session={session} opening={opening} />
</Link>
{error ? <p className="session-open-error" role="alert">{error}</p> : null}
</div>
);
}
const {
disabled,
menuOpen,
onMenuOpenChange,
onSelect,
onTogglePinned,
onRename,
onShare,
onToggleArchived,
onDelete,
} = props;
return (
<Menu.Root
open={sessionMutationMenuVisible(menuOpen, disabled)}
@@ -98,12 +150,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
disabled={disabled}
onClick={onSelect}
>
<span className="session-title">
{session.pinned ? <Star aria-label="已收藏" /> : null}
<span className="truncate">{session.title}</span>
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
</span>
{session.subtitle ? <small className="session-subtitle">{session.subtitle}</small> : null}
<SessionRowBody session={session} opening={opening} />
</SidebarMenuButton>
<Menu.Trigger
className="session-menu-trigger"
+34 -2
View File
@@ -2,6 +2,7 @@
import { Tooltip } from "@base-ui/react/tooltip";
import { PanelLeft } from "lucide-react";
import Link from "next/link";
import {
createContext,
forwardRef,
@@ -17,7 +18,9 @@ import {
import { useSidebarViewport } from "@/hooks/use-sidebar-viewport";
import {
defaultSidebarOpen,
readStoredSidebarOpen,
shouldHandleSidebarShortcut,
writeStoredSidebarOpen,
type SidebarViewport,
} from "@/lib/sidebar-state";
import { cn } from "@/lib/utils";
@@ -58,6 +61,11 @@ type SidebarMenuButtonProps = ComponentProps<"button"> & {
readonly tooltip?: string;
};
type SidebarMenuLinkProps = ComponentProps<typeof Link> & {
readonly isActive?: boolean;
readonly tooltip?: string;
};
const SidebarContext = createContext<SidebarProviderContextValue | null>(null);
function useSidebarContext(): SidebarProviderContextValue {
@@ -93,9 +101,13 @@ export function SidebarProvider({
const commitOpen = useCallback((nextOpen: boolean) => {
userChangedDesktopState.current = true;
/* Remembered across pages so collapsing on `/` survives the trip to
`/chart`. Mobile is skipped inside the helper: the drawer is not a
preference. */
writeStoredSidebarOpen(viewport, nextOpen);
if (!isControlled) setUncontrolledOpen(nextOpen);
onOpenChange?.(nextOpen);
}, [isControlled, onOpenChange]);
}, [isControlled, onOpenChange, viewport]);
const setOpen = useCallback((nextOpen: boolean) => {
commitOpen(nextOpen);
@@ -110,7 +122,11 @@ export function SidebarProvider({
useEffect(() => {
if (ready && !isControlled && !userChangedDesktopState.current) {
setUncontrolledOpen(defaultSidebarOpen(viewport));
/* Same frame the viewport default was already applied in, so a stored
preference costs no flash the breakpoint default did not already
cost. localStorage cannot be read during the server render and `/` has
to stay `○ Static`, so this is as early as the value can arrive. */
setUncontrolledOpen(readStoredSidebarOpen(viewport) ?? defaultSidebarOpen(viewport));
}
}, [isControlled, ready, viewport]);
@@ -224,6 +240,22 @@ export function SidebarMenuButton({ className, isActive = false, tooltip, ...pro
return <Tooltip.Root><Tooltip.Trigger render={button} /><Tooltip.Portal><Tooltip.Positioner side="right" sideOffset={8}><Tooltip.Popup>{tooltip}</Tooltip.Popup></Tooltip.Positioner></Tooltip.Portal></Tooltip.Root>;
}
/**
* A nav row that is a destination rather than an action.
*
* Same element contract as `SidebarMenuButton` — `data-sidebar="menu-button"`,
* `data-active`, the 44px minimum, the collapsed-rail tooltip — so the CSS and
* the collapsed rail cannot tell the two apart. 星盘 / 星历 / 我的报告 are
* links on every page now; the chat page used to reach them with
* `window.location.assign`, which threw the whole React tree away.
*/
export function SidebarMenuLink({ className, isActive = false, tooltip, ...props }: SidebarMenuLinkProps) {
const { isMobile, state } = useSidebar();
const link = <Link data-sidebar="menu-button" data-slot="sidebar-menu-button" data-active={isActive} className={cn("min-h-11 w-full", className)} {...props} />;
if (tooltip === undefined || state !== "collapsed" || isMobile) return link;
return <Tooltip.Root><Tooltip.Trigger render={link} /><Tooltip.Portal><Tooltip.Positioner side="right" sideOffset={8}><Tooltip.Popup>{tooltip}</Tooltip.Popup></Tooltip.Positioner></Tooltip.Portal></Tooltip.Root>;
}
export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return <div data-sidebar="footer" data-slot="sidebar-footer" className={cn("shrink-0", className)} {...props} />;
}
@@ -108,7 +108,6 @@ export type ConsultationRunParams = {
pendingConsultation: MutableRefObject<PendingConsultation | null>;
pendingSessionId: string | null;
profile: Profile;
router: { push: (href: string) => void };
sessions: ChatSession[];
setAccount: Dispatch<SetStateAction<Account | null>>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
@@ -166,7 +165,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
pendingConsultation,
pendingSessionId,
profile,
router: _router,
sessions,
setAccount,
setActiveSessionId,
-122
View File
@@ -1,122 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { BeamAvatar } from "@/lib/beam-avatar";
/**
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
*
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
* rollback included. Lifting that whole layer so four routes can share it is far
* more than these pages need: none of them offers session management. Two GETs
* are enough, and this hook never writes.
*/
export type NavRailSession = {
readonly id: string;
readonly title: string;
readonly pinned: boolean;
readonly updatedAt: number;
};
export type NavRailAccount = {
readonly name: string;
readonly initial: string;
readonly credits: number;
/** The same beam avatar the chat sidebar draws, so the two footers match. */
readonly avatar: BeamAvatar | null;
};
export type NavRailState = {
readonly sessions: readonly NavRailSession[];
readonly account: NavRailAccount | null;
/** True once both requests have settled, however they settled. */
readonly settled: boolean;
/** The viewer is not signed in; the rail degrades to brand plus a login link. */
readonly signedOut: boolean;
};
type SessionRow = {
id?: unknown;
title?: unknown;
pinned?: unknown;
updated_at?: unknown;
};
function toSession(row: SessionRow): NavRailSession | null {
if (typeof row.id !== "string" || !row.id) return null;
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
return {
id: row.id,
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
pinned: row.pinned === true,
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
};
}
export function useNavRail(): NavRailState {
const [sessions, setSessions] = useState<readonly NavRailSession[]>([]);
const [account, setAccount] = useState<NavRailAccount | null>(null);
const [settled, setSettled] = useState(false);
const [signedOut, setSignedOut] = useState(false);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
async function load() {
if (typeof fetch !== "function") {
setSettled(true);
return;
}
const [sessionResult, accountResult] = await Promise.allSettled([
fetch("/api/sessions?limit=40", { signal: controller.signal }),
fetch("/api/account", { signal: controller.signal }),
]);
if (cancelled) return;
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
if (!cancelled) setSessions(rows.map(toSession).filter((item): item is NavRailSession => item !== null));
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
if (!cancelled) setSignedOut(true);
}
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
const body = await accountResult.value.json().catch(() => null) as {
credits?: unknown;
user?: { email?: unknown };
profile?: { name?: unknown };
avatar?: BeamAvatar | null;
} | null;
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
if (!cancelled) {
setAccount({
name: name || email || "账户",
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: typeof body?.credits === "number" ? body.credits : 0,
avatar: body?.avatar ?? null,
});
}
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
if (!cancelled) setSignedOut(true);
}
if (!cancelled) setSettled(true);
}
void load().catch(() => {
if (!cancelled) setSettled(true);
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
return { sessions, account, settled, signedOut };
}
+11 -2
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { sortSessions } from "@/lib/session-groups";
import { invalidateSidebarCache } from "@/lib/sidebar-data-cache";
import {
clearBirthTimeConsultationConsent,
type BirthTimeConsultationConsentState,
@@ -216,6 +217,12 @@ export function useSessionManagement(params: SessionManagementParams) {
updateSession(session.id, () => nextSession);
try {
await persistSession(nextSession);
/* The secondary pages read their list from a 60s module cache; without
this, walking to /chart right after a rename still shows the old
title. Invalidate rather than write through: `Home()` owns the richer
ChatSession shape, and one refetch is cheaper than keeping two
representations in step. */
invalidateSidebarCache();
} catch (caught) {
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
}
@@ -236,6 +243,7 @@ export function useSessionManagement(params: SessionManagementParams) {
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
const payload = await response.json().catch(() => null) as { error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败");
invalidateSidebarCache();
} catch (caught) {
setSessions(previousSessions);
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
@@ -247,7 +255,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!session) return;
const nextPinned = !session.pinned;
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
void writeChatSession(sessionId, { pinned: nextPinned }, "update").then(invalidateSidebarCache).catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
});
@@ -266,7 +274,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
if (!restoring && previousActiveId === sessionId) {
setActiveSessionId(previousActiveId);
@@ -330,6 +338,7 @@ export function useSessionManagement(params: SessionManagementParams) {
? { continuedFromSessionId: options.continuedFromSessionId }
: undefined,
);
invalidateSidebarCache();
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useEffect, useState } from "react";
import type { SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
import type { BeamAvatar } from "@/lib/beam-avatar";
import {
invalidateSidebarCache,
readSidebarCache,
sidebarCacheIsFresh,
writeSidebarCache,
} from "@/lib/sidebar-data-cache";
/**
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
*
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
* rollback included. Lifting that whole layer so four routes can share it is far
* more than these pages need: none of them offers session management. Two GETs
* are enough, and this hook never writes.
*
* It is called once, from the `(secondary)` layout, so moving between those
* four routes does not re-run it at all. The module cache behind it covers the
* other trip: leaving for `/` and coming back unmounts the layout, and without
* it that would mean two more requests inside the same minute.
*/
export type SidebarDataState = {
readonly sessions: readonly SidebarSession[];
readonly account: SidebarAccount | null;
/** True once both requests have settled, however they settled. */
readonly settled: boolean;
/** The viewer is not signed in; the sidebar degrades to brand plus a login link. */
readonly signedOut: boolean;
};
type SessionRow = {
id?: unknown;
title?: unknown;
pinned?: unknown;
archived_at?: unknown;
updated_at?: unknown;
};
type AccountBody = {
credits?: unknown;
user?: { id?: unknown; email?: unknown };
profile?: { name?: unknown };
avatar?: BeamAvatar | null;
};
const EMPTY: SidebarDataState = { sessions: [], account: null, settled: false, signedOut: false };
export function toSidebarSession(row: SessionRow): SidebarSession | null {
if (typeof row.id !== "string" || !row.id) return null;
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
return {
id: row.id,
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
pinned: row.pinned === true,
archived: typeof row.archived_at === "string" && row.archived_at !== "",
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
};
}
export function toSidebarAccount(body: AccountBody | null): SidebarAccount {
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
return {
name: name || email || "账户",
email,
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: typeof body?.credits === "number" ? body.credits : 0,
avatar: body?.avatar ?? null,
};
}
/** Which account the payload belongs to; the cache key. */
export function sidebarAccountKey(body: AccountBody | null): string {
const id = typeof body?.user?.id === "string" ? body.user.id : "";
const email = typeof body?.user?.email === "string" ? body.user.email : "";
return id || email || "anonymous";
}
export function useSidebarData(): SidebarDataState {
const [state, setState] = useState<SidebarDataState>(() => {
/* Read once, at mount: an entry from a previous visit renders in the first
frame rather than after a round trip. Empty on the server, and empty on a
real page load, which is what makes the first render match. */
const cached = readSidebarCache();
if (cached === null) return EMPTY;
return { sessions: cached.sessions, account: cached.account, settled: true, signedOut: false };
});
useEffect(() => {
const cached = readSidebarCache();
if (sidebarCacheIsFresh(cached, Date.now())) return;
const controller = new AbortController();
let cancelled = false;
async function load() {
if (typeof fetch !== "function") {
setState((current) => ({ ...current, settled: true }));
return;
}
const [sessionResult, accountResult] = await Promise.allSettled([
fetch("/api/sessions?limit=40", { signal: controller.signal }),
fetch("/api/account", { signal: controller.signal }),
]);
if (cancelled) return;
let sessions: readonly SidebarSession[] | null = null;
let account: SidebarAccount | null = null;
let accountBody: AccountBody | null = null;
let signedOut = false;
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
sessions = rows.map(toSidebarSession).filter((item): item is SidebarSession => item !== null);
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
signedOut = true;
}
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
accountBody = await accountResult.value.json().catch(() => null) as AccountBody | null;
account = toSidebarAccount(accountBody);
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
signedOut = true;
}
if (cancelled) return;
if (signedOut) {
/* A rejected session must not leave a list on screen, here or on the
next page this tab opens. */
invalidateSidebarCache();
setState({ sessions: [], account: null, settled: true, signedOut: true });
return;
}
if (sessions !== null || account !== null) {
writeSidebarCache({
accountId: sidebarAccountKey(accountBody),
sessions: sessions ?? [],
account,
fetchedAt: Date.now(),
});
}
setState({ sessions: sessions ?? [], account, settled: true, signedOut: false });
}
void load().catch(() => {
if (!cancelled) setState((current) => ({ ...current, settled: true }));
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
return state;
}
+58
View File
@@ -0,0 +1,58 @@
import type { SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
/**
* The session list and account the read-only sidebar shows, kept in memory for
* one tab.
*
* `/chart`, `/ephemeris` and `/reports` used to re-issue `GET /api/sessions` and
* `GET /api/account` on every arrival, because each page mounted its own shell.
* The shared `(secondary)` layout removes the per-page remount; this removes the
* repeat when the reader leaves for `/` and comes back. Nothing durable is
* written: a stale session list surviving a browser restart is worse than one
* fetch, and it would outlive a sign-out.
*
* Keyed by account id so a second account in the same tab never reads the
* first one's rows. The pointer is what makes a synchronous read possible: the
* account id only arrives with the payload, so the reader cannot name its own
* key before the first fetch has happened.
*/
export const SIDEBAR_CACHE_TTL_MS = 60_000;
export type SidebarCacheEntry = {
readonly accountId: string;
readonly sessions: readonly SidebarSession[];
readonly account: SidebarAccount | null;
readonly fetchedAt: number;
};
const entries = new Map<string, SidebarCacheEntry>();
let currentAccountId: string | null = null;
export function readSidebarCache(): SidebarCacheEntry | null {
if (currentAccountId === null) return null;
return entries.get(currentAccountId) ?? null;
}
export function writeSidebarCache(entry: SidebarCacheEntry): void {
entries.set(entry.accountId, entry);
currentAccountId = entry.accountId;
}
/** True when the entry may be shown without going back to the network. */
export function sidebarCacheIsFresh(entry: SidebarCacheEntry | null, now: number): boolean {
if (entry === null) return false;
const age = now - entry.fetchedAt;
return age >= 0 && age < SIDEBAR_CACHE_TTL_MS;
}
/**
* Called from `/` after every session write — create, rename, delete, archive,
* pin — and on any 401. Renaming a session and walking to `/chart` has to show
* the new title, and a signed-out tab must not keep a list on screen.
*/
export function invalidateSidebarCache(): void {
entries.clear();
currentAccountId = null;
}
+53
View File
@@ -37,3 +37,56 @@ export function shouldHandleSidebarShortcut(event: SidebarShortcutEvent): boolea
&& !event.altKey
&& !event.shiftKey;
}
/**
* Where the desktop/tablet collapse state is remembered across pages.
*
* localStorage, not the shadcn `sidebar_state` cookie: `/` is a `○ Static`
* route and `/chart` and `/ephemeris` are static too, so reading a cookie on
* the server would opt all three out of static rendering. A cookie that only
* the client may read buys nothing a localStorage key does not, so this is the
* cheaper half of the D5 choice. The mobile drawer is deliberately excluded:
* reopening a phone drawer on every navigation is not a preference anyone set.
*/
export const SIDEBAR_STATE_STORAGE_KEY = "sidebar_state";
type SidebarStateStorage = Pick<Storage, "getItem" | "setItem">;
function sidebarStateStorage(): SidebarStateStorage | null {
try {
return globalThis.localStorage ?? null;
} catch {
/* Private mode and blocked site data both throw on access. */
return null;
}
}
/** `null` when nothing was stored, or when storage is unavailable. */
export function readStoredSidebarOpen(
viewport: SidebarViewport,
storage: SidebarStateStorage | null = sidebarStateStorage(),
): boolean | null {
if (viewport === "mobile" || storage === null) return null;
try {
const raw = storage.getItem(SIDEBAR_STATE_STORAGE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
} catch {
return null;
}
}
export function writeStoredSidebarOpen(
viewport: SidebarViewport,
open: boolean,
storage: SidebarStateStorage | null = sidebarStateStorage(),
): void {
if (viewport === "mobile" || storage === null) return;
try {
storage.setItem(SIDEBAR_STATE_STORAGE_KEY, open ? "true" : "false");
} catch {
/* Quota and private mode: the sidebar still works, it just forgets. */
}
}