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
+42 -21
View File
@@ -346,7 +346,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar.
- **Motion:** Sidebar state changes are immediate on desktop, tablet, and mobile. The 44px trigger keeps one stable 18px sidebar glyph and never enters an intermediate scale or opacity state.
- **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support.
- **State:** session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence.
- **State:** the desktop and tablet collapse state is remembered per browser under the `sidebar_state` localStorage key, so collapsing on `/` survives the walk to `/chart` and back. Nothing is stored until someone actually toggles it; with nothing stored the breakpoint defaults stand. The mobile drawer stays session-local and is never remembered — reopening it on every navigation is not a preference anyone set. localStorage rather than the shadcn `sidebar_state` cookie because `/`, `/chart` and `/ephemeris` are static routes and a server-read cookie would opt all three out of static rendering; the stored value is therefore applied in the same post-mount effect that already applied the breakpoint default, and costs no frame that default did not already cost.
### Message
@@ -376,25 +376,46 @@ Text release is paced, not animated: the frame buffer commits at most once per a
### Secondary page shell
`/chart`, `/ephemeris` and `/reports` share the chat shell through
`SecondaryShell`. Each used to own a full-screen layout — a `*-shell` root, a
`*-topbar` with one 「返回对话」 link, and a `*-hero` with a page-sized h1 —
three copies of one skeleton, none of them carrying the sidebar.
`/chart`, `/ephemeris`, `/reports` and `/reports/[reportId]` share the chat
shell. Each used to own a full-screen layout — a `*-shell` root, a `*-topbar`
with one 「返回对话」 link, and a `*-hero` with a page-sized h1 — three copies of
one skeleton, none of them carrying the sidebar.
- **Composition:** `SidebarProvider``AppNavRail` `SidebarInset.chat-panel.secondary-panel`.
The header is the same 46px row the chat page uses: sidebar trigger, page name,
an optional quiet note chip, and up to two right-aligned actions. The body is
the only thing that scrolls, and it owns the page floor (`--color-canvas-soft`).
- **The rail is read-only.** It carries the brand, 新建对话, the three page
actions, and one flat 「最近」 list whose rows navigate to `/?c=<uuid>`.
Renaming, pinning, archiving, deleting and the account menu stay on `/`: they
are backed by `Home()`'s optimistic-update and rollback layer, and lifting that
so four routes can share it is far more than these pages need. The footer shows
identity and balance as text, not as a menu trigger.
- **Loading:** the rail renders its nav immediately and fills the list when
- **Composition:** `app/(secondary)/layout.tsx` = `SidebarProvider`
`AppSidebar` (read-only) → `SidebarInset.chat-panel.secondary-panel`. The
route group changes no URL; what it changes is that the four routes share
**one** shell instance, so moving between them neither remounts the sidebar
nor re-reads the session list. Each page contributes only `SecondaryHeader`:
the same 46px row the chat page uses — sidebar trigger, page name, an optional
quiet note chip, and up to two right-aligned actions. The body is the only
thing that scrolls, and it owns the page floor (`--color-canvas-soft`).
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
product. `/` passes `controls`; the secondary layout does not, and without it
the same markup renders read-only. There used to be a second component for
these pages, and two components meant two shapes: its rows had no
`.session-main`, so they lost the 44px minimum, the row padding and the 2px
current-item marker, while `.session-row` still reserved a 44px column for a
menu button it never rendered.
- **What read-only drops — exactly three things.** The per-row menu trigger (and
with it the reserved column, via `.session-row[data-readonly="true"]`), the
footer chevron, and the account menu behind it. Everything else is the same
element, the same class and the same size: brand row, 新建对话, the three page
actions, the flat 「最近」 list, the 56px `.profile-trigger`. Rows are links to
`/?c=<uuid>`; the footer is a link to `/`. Renaming, pinning, archiving and
deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback
layer, which is far more than these four routes need.
- **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are
`<Link>` on every page, including `/`, where they used to be a full document
load that threw away the React tree, the session list and the account. Only
`/login` stays a hard exit, and `persistLoginSessionReturn()` still stashes the
`?c=` before any of them.
- **Loading:** the sidebar renders its nav immediately and fills the list when
`/api/sessions` returns. While that is in flight the list area carries static
copy — never a skeleton, per the unified-loading ruling.
- **Signed out:** the rail degrades to brand, nav, a 「登录后可以看到你的对话」
copy — never a skeleton, per the unified-loading ruling. A list read in the
last 60 seconds is reused from memory and shown in the first frame; the entry
is keyed by account, never persisted, dropped on any session write on `/` and
on any 401.
- **Signed out:** the sidebar degrades to brand, nav, a 「登录后可以看到你的对话」
line, and a 去登录 footer. It never renders an error state of its own.
- **Page name:** lives in the header, so none of these pages carries an `h1` any
more. A page's own reassurance copy (the chart page's cost-and-speed eyebrow,
@@ -420,7 +441,7 @@ three copies of one skeleton, none of them carrying the sidebar.
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the nav rail and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
- **Print inside the shell:** printing takes the whole document, so the reader ships a second `media="print"` stylesheet that hides the sidebar and the 46px header **and unlocks them**: `.chat-app` / `.chat-panel` are `height: 100%; overflow: hidden`, which would otherwise clip a nine-section report to one page. It lives on the component rather than in `globals.css` so it exists only while the reader is mounted and no other surface has to know about it. The `@page` rule is unchanged.
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 112. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
@@ -704,7 +725,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
### 结构
页名「星盘」与出生资料一行在 `SecondaryShell` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行,眉标「主盘直接算 · 分盘按需 · 不消耗点数」跟在 Tab 行下方作为一行静默说明。等待外壳和失败页只有原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行,眉标「主盘直接算 · 分盘按需 · 不消耗点数」跟在 Tab 行下方作为一行静默说明。等待外壳和失败页只有原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。
五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。五个等重的药丸按钮会读成五个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。
@@ -750,7 +771,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 16. 星历页
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `SecondaryShell` 上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。
- **结构:** 日期条;今日五要素;当日行运;未来九十天;收尾一句。顺序固定。
- **日期条:** 居中的 ` 日期 `——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
@@ -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. */
}
}