diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 489f8c10..16f745e2 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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=`. - 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=`; 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 + `` 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 1–12. 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 `` 是独立设计系统,不在此表。 ### 结构 -页名「星盘」与出生资料一行在 `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 `` 是独立设计系统,不在此表。 ## 16. 星历页 -独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `SecondaryShell` 上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。 +独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。 - **结构:** 日期条;今日五要素;当日行运;未来九十天;收尾一句。顺序固定。 - **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。 diff --git a/frontend/src/app/chart/page.tsx b/frontend/src/app/(secondary)/chart/page.tsx similarity index 92% rename from frontend/src/app/chart/page.tsx rename to frontend/src/app/(secondary)/chart/page.tsx index d2838519..8323833e 100644 --- a/frontend/src/app/chart/page.tsx +++ b/frontend/src/app/(secondary)/chart/page.tsx @@ -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", diff --git a/frontend/src/app/ephemeris/page.tsx b/frontend/src/app/(secondary)/ephemeris/page.tsx similarity index 92% rename from frontend/src/app/ephemeris/page.tsx rename to frontend/src/app/(secondary)/ephemeris/page.tsx index e3ed41de..f040cc37 100644 --- a/frontend/src/app/ephemeris/page.tsx +++ b/frontend/src/app/(secondary)/ephemeris/page.tsx @@ -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", diff --git a/frontend/src/app/(secondary)/layout.tsx b/frontend/src/app/(secondary)/layout.tsx new file mode 100644 index 00000000..5451322d --- /dev/null +++ b/frontend/src/app/(secondary)/layout.tsx @@ -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 ( + +
+ + + {children} + +
+
+ ); +} diff --git a/frontend/src/app/reports/[reportId]/error.tsx b/frontend/src/app/(secondary)/reports/[reportId]/error.tsx similarity index 96% rename from frontend/src/app/reports/[reportId]/error.tsx rename to frontend/src/app/(secondary)/reports/[reportId]/error.tsx index c5496b1e..0e91ce4b 100644 --- a/frontend/src/app/reports/[reportId]/error.tsx +++ b/frontend/src/app/(secondary)/reports/[reportId]/error.tsx @@ -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, diff --git a/frontend/src/app/reports/[reportId]/loading.tsx b/frontend/src/app/(secondary)/reports/[reportId]/loading.tsx similarity index 90% rename from frontend/src/app/reports/[reportId]/loading.tsx rename to frontend/src/app/(secondary)/reports/[reportId]/loading.tsx index efd8355c..9c79e540 100644 --- a/frontend/src/app/reports/[reportId]/loading.tsx +++ b/frontend/src/app/(secondary)/reports/[reportId]/loading.tsx @@ -1,5 +1,5 @@ import { InlineSpinner } from "@/components/inline-spinner"; -import "../../site-styles"; +import "../../../site-styles"; export default function ReportLoading() { return ( diff --git a/frontend/src/app/reports/[reportId]/not-found.tsx b/frontend/src/app/(secondary)/reports/[reportId]/not-found.tsx similarity index 93% rename from frontend/src/app/reports/[reportId]/not-found.tsx rename to frontend/src/app/(secondary)/reports/[reportId]/not-found.tsx index 7479773e..f28e6c9e 100644 --- a/frontend/src/app/reports/[reportId]/not-found.tsx +++ b/frontend/src/app/(secondary)/reports/[reportId]/not-found.tsx @@ -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 ( diff --git a/frontend/src/app/reports/[reportId]/page.tsx b/frontend/src/app/(secondary)/reports/[reportId]/page.tsx similarity index 94% rename from frontend/src/app/reports/[reportId]/page.tsx rename to frontend/src/app/(secondary)/reports/[reportId]/page.tsx index 91fcc27a..c99c91b5 100644 --- a/frontend/src/app/reports/[reportId]/page.tsx +++ b/frontend/src/app/(secondary)/reports/[reportId]/page.tsx @@ -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"; diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/(secondary)/reports/page.tsx similarity index 93% rename from frontend/src/app/reports/page.tsx rename to frontend/src/app/(secondary)/reports/page.tsx index 1609f99d..1ed43238 100644 --- a/frontend/src/app/reports/page.tsx +++ b/frontend/src/app/(secondary)/reports/page.tsx @@ -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"; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index dc87db98..75b8e493 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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. */ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 64918316..90cf7e69 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(null); const [chartLibrary, setChartLibrary] = useState([]); @@ -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 ? (
setPendingSessionDeletion(null)}> diff --git a/frontend/src/components/app-nav-rail.tsx b/frontend/src/components/app-nav-rail.tsx deleted file mode 100644 index 6f2d1656..00000000 --- a/frontend/src/components/app-nav-rail.tsx +++ /dev/null @@ -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 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 ( - - - {session.title} - - - ); - } - - return ( - - -
-
- - - - - {NAV_PAGES.map(({ href, label, icon: Icon }) => ( - - - - ))} - -
- - - {showExpandedContent ? ( - -

最近

- - {/* Static copy while the list is in flight — never a skeleton. */} - {sessions.length === 0 ? ( -

- {signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"} -

- ) : ( - - {pinned.length > 0 ? ( -
-

-

- {pinned.map(renderRow)} -
- ) : null} - {groups.map((group) => ( -
-

{group.label}

- {group.sessions.map(renderRow)} -
- ))} -
- )} -
-
- ) : null} -
- - - {signedOut ? ( -
- - {showExpandedContent ? 去登录 : null} - - ) : ( - /* 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. */ - - {account?.avatar - ? - : } - {showExpandedContent && account ? ( - <> - {account.name} - {account.credits} 点 - - ) : null} - - )} - - - - ); -} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 23c273a3..277ba6ba 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -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; 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(null); const historyHeadingRef = useRef(null); const loadMoreRef = useRef(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 = 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 ( - 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 ? ( + 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)} + /> + ) : ( + + )} ); } @@ -195,69 +236,58 @@ export function AppSidebar({
- - - - - - - - - - - - - - + {controls ? ( + + + ) : ( + + + )} + {NAV_PAGES.map(({ href, label, icon: Icon }) => ( + + + + + ))} {showExpandedContent ? ( - + {/* One flat list. It was three stacked sections — a 星盘列表 chip grid and two
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. */}

- {sessionControls.showingArchived ? "归档记录" : "最近"} + {sessionControls?.showingArchived ? "归档记录" : "最近"}

{favoriteSessions.length === 0 && historySessions.length === 0 ? ( -

暂无对话,点上方「新建对话」开始

+ /* Static copy while a read-only list is in flight — never a skeleton. */ +

+ {signedOut ? "登录后可以看到你的对话" : settled ? "暂无对话,点上方「新建对话」开始" : "对话列表读取中"} +

) : ( {favoriteSessions.length > 0 ? ( @@ -278,7 +308,7 @@ export function AppSidebar({ ))} ))} - {sessionControls.hasMore ?
: null} + {hasMoreSessions ?
: null} )} @@ -300,54 +330,77 @@ export function AppSidebar({ - - - {account.avatar - ? - : } - {showExpandedContent ? {account.name} : null} - {showExpandedContent ? - - + - -
- {account.avatar - ? - : } - {account.name}{account.email} -
- - - - - - - - - - - - - -
-
-
-
+ {account.avatar + ? + : } + {showExpandedContent ? {account.name} : null} + {showExpandedContent ?
diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index c45e2fac..f47ecc6c 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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 ( - + <> + {view == null ? (

{CHART_VIEW_COPY.waitingChart}

@@ -122,6 +123,6 @@ export function ChartPageView({
)} - + ); } diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index 8a4312d7..49d58a8d 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -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 ( - + <> +

{EPHEMERIS_COPY.loginTitle}

{EPHEMERIS_COPY.loginBody}

-
+ ); } @@ -126,14 +127,15 @@ export function EphemerisView(props: { const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined; return ( - - {EPHEMERIS_COPY.ask} - - )} - > + <> + + {EPHEMERIS_COPY.ask} + + )} + />
{/* `‹ 日期 ›`: 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. */}

{EPHEMERIS_COPY.footerNote}

-
+ ); } diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 044932a1..5554ce0a 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -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 ( - + <> +
-
+ ); } return ( - void load()} />} - > + <> + void load()} />} + />
@@ -278,6 +280,6 @@ export function PersonalReportCenter() { )}
-
+ ); } diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 12c176af..5fdbece8 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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 ( - + <> +
{writing ? null : }

@@ -331,13 +332,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) { )}

-
+ ); } if (state.phase === "timed-out") { return ( - + <> +
- + ); } if (state.phase === "unauthorized") { return ( - + <> +

请先登录

个人报告仅对登录用户开放。请登录后重试。

@@ -366,13 +369,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) { 去登录
-
+ ); } if (state.phase === "not-found") { return ( - + <> +

报告不存在

该报告不存在、已删除,或不属于当前账号。

@@ -380,13 +384,14 @@ export function PersonalReportPage({ reportId }: { reportId: string }) { 返回报告中心
-
+ ); } if (state.phase === "failed") { return ( - + <> +
-
+ ); } if (state.phase === "invalid" || state.phase === "network-error") { return ( - + <> +
-
+ ); } if (state.phase === "legacy-unavailable") { return ( - + <> +
-
+ ); } if (state.phase !== "markdown-ready") { return ( - + <> +
-
+ ); } return ( - + <> +
@@ -466,6 +475,6 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
-
+ ); } diff --git a/frontend/src/components/secondary-header.tsx b/frontend/src/components/secondary-header.tsx new file mode 100644 index 00000000..01548f9b --- /dev/null +++ b/frontend/src/components/secondary-header.tsx @@ -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 ( +
+ +
+ {title} + {note ? {note} : null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/frontend/src/components/secondary-shell.tsx b/frontend/src/components/secondary-shell.tsx deleted file mode 100644 index 6766dd28..00000000 --- a/frontend/src/components/secondary-shell.tsx +++ /dev/null @@ -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 ( - -
- - -
- -
- {title} - {note ? {note} : null} -
- {actions ?
{actions}
: null} -
- {children} -
-
-
- ); -} diff --git a/frontend/src/components/sidebar-session-row.tsx b/frontend/src/components/sidebar-session-row.tsx index 8502452e..97707790 100644 --- a/frontend/src/components/sidebar-session-row.tsx +++ b/frontend/src/components/sidebar-session-row.tsx @@ -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(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=` 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 ( + <> + + {session.pinned ? : null} + {session.title} + {opening ? {RECTIFICATION_SIDEBAR_OPENING_NOTE} : null} + + {session.subtitle ? {session.subtitle} : null} + + ); +} + +export const SidebarSessionRow = forwardRef(function SidebarSessionRow(props, ref) { + const { session, active, opening = false, error } = props; + + if (props.href !== undefined) { + return ( +
+ + + + {error ?

{error}

: null} +
+ ); + } + + const { + disabled, + menuOpen, + onMenuOpenChange, + onSelect, + onTogglePinned, + onRename, + onShare, + onToggleArchived, + onDelete, + } = props; + return ( - - {session.pinned ? : null} - {session.title} - {opening ? {RECTIFICATION_SIDEBAR_OPENING_NOTE} : null} - - {session.subtitle ? {session.subtitle} : null} + & { readonly tooltip?: string; }; +type SidebarMenuLinkProps = ComponentProps & { + readonly isActive?: boolean; + readonly tooltip?: string; +}; + const SidebarContext = createContext(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}; } +/** + * 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 = ; + if (tooltip === undefined || state !== "collapsed" || isMobile) return link; + return {tooltip}; +} + export function SidebarFooter({ className, ...props }: ComponentProps<"div">) { return
; } diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 441e85d7..ec0aa22f 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -108,7 +108,6 @@ export type ConsultationRunParams = { pendingConsultation: MutableRefObject; pendingSessionId: string | null; profile: Profile; - router: { push: (href: string) => void }; sessions: ChatSession[]; setAccount: Dispatch>; setActiveSessionId: Dispatch>; @@ -166,7 +165,6 @@ export function useConsultationRun(params: ConsultationRunParams) { pendingConsultation, pendingSessionId, profile, - router: _router, sessions, setAccount, setActiveSessionId, diff --git a/frontend/src/hooks/use-nav-rail.ts b/frontend/src/hooks/use-nav-rail.ts deleted file mode 100644 index c8833ae3..00000000 --- a/frontend/src/hooks/use-nav-rail.ts +++ /dev/null @@ -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([]); - const [account, setAccount] = useState(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 }; -} diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index af027c98..fc19b6ba 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -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)); diff --git a/frontend/src/hooks/use-sidebar-data.ts b/frontend/src/hooks/use-sidebar-data.ts new file mode 100644 index 00000000..c280958c --- /dev/null +++ b/frontend/src/hooks/use-sidebar-data.ts @@ -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(() => { + /* 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; +} diff --git a/frontend/src/lib/sidebar-data-cache.ts b/frontend/src/lib/sidebar-data-cache.ts new file mode 100644 index 00000000..d61ba9d6 --- /dev/null +++ b/frontend/src/lib/sidebar-data-cache.ts @@ -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(); +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; +} diff --git a/frontend/src/lib/sidebar-state.ts b/frontend/src/lib/sidebar-state.ts index 02f4075c..33b23287 100644 --- a/frontend/src/lib/sidebar-state.ts +++ b/frontend/src/lib/sidebar-state.ts @@ -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; + +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. */ + } +} +