# TASK · 侧栏统一成一个组件 + 次级页共享外壳 + 跳转不再整页刷新(2026-09-16)
分支:`codex/sidebar-unify-20260916`。纯前端,不动数据库、不动 Skill、不动 Python。
## 基线 commit
`origin/staging` = `cfb41daf`(feat(rectification): 出卡加精度门槛,补经历改成系统点名)。本单涉及的文件自 `317e9f18` 起没有变化,进度记录里按开工时的 `git rev-parse origin/staging` 重新写一次。
## 事故实证(行号按符号定位,会漂)
产品负责人在 staging 真机上的三条观察,逐条对到代码:
1. **首页与三个次级页的侧栏长得不一样。**
- `/` 用 `AppSidebar`(`frontend/src/components/app-sidebar.tsx`),会话行是 `SidebarSessionRow`(`sidebar-session-row.tsx`:`.session-row > .session-main` + 菜单按钮 + 副标题),页脚是 `.profile-trigger`(56px、带 chevron、打开账户菜单)。
- `/chart`、`/ephemeris`、`/reports`、`/reports/[reportId]` 用 `AppNavRail`(`app-nav-rail.tsx`),会话行在 `renderRow` 里是 `` 直接套 `.session-title`,**没有 `.session-main` 这一层**:丢了 `padding: var(--space-2)`、`min-height: 44px`、当前项 2px 左标记(`globals.css` 的 `.session-main[data-active="true"]::before`),而 `.session-row` 的 `grid-template-columns: minmax(0, 1fr) 44px` 还给不存在的菜单按钮留着一列 44px 空位。页脚是 `.nav-rail-identity`(44px、flex、文字「N 点」)。
- 两个组件共用一份 `globals.css`,所以「四个导航项」本身一致(`.new-chat` 强调色、`.report-nav-button` 文字色,`DESIGN.md` §Scarce 第 1 条写明这是设计);分叉全在会话行与页脚。
- `SidebarProvider`(`ui/sidebar.tsx`,`defaultOpen = true`)不存 cookie / localStorage,折叠状态只活在内存里。
2. **每次进次级页都重新读会话列表。**
- `app-sidebar.tsx` 的 `leaveChat()` 用 `window.location.assign(path)`:首页 → 次级页是**整页刷新**,React 树与内存全部清零。
- 三个次级页各自在组件内部渲染 `SecondaryShell`(`chart-page-view.tsx`、`ephemeris-page.tsx`、`personal-report-center.tsx`、`personal-report-page.tsx` 共 8 处调用),每个 `SecondaryShell` 挂一份 `SidebarProvider + AppNavRail`。`app/` 下没有把这四个路由包起来的 layout,所以次级页之间即便是客户端跳转,外壳也整个重挂,`useNavRail()`(`hooks/use-nav-rail.ts`)的 effect 重新 `GET /api/sessions?limit=40` + `GET /api/account`,且无任何缓存。
3. **从次级页回「新建对话」等很久。**
- `AppNavRail` 里「新建对话」是 ``,客户端跳转本身没问题;慢在 `Home()` 的启动链每次都从零跑:`page.tsx` 的 `loadCloudData()` 先 `Promise.all(账户, 模型目录, 会话列表)` → `fetchActiveConsultationStatus` → `resolveLookupBootstrap` → 当前会话 `fetchSessionDetail` → `setBootstrapPhase("prepare")` → 「准备」阶段并行拉 `/api/rectification/cases/entry-summary` 与 `POST /api/daily-starlanguage` → `bootstrapRevealDelayMs`(`lib/home-bootstrap.ts`,上限 `BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000`)→ `setHydrated(true)` 揭幕。串行至少 4 次往返。
- 2026-09-16 从验收机测 staging:接口单次 TTFB 0.63~0.90 s;`/` 首屏脚本 23 个 gzip 604 KB,`/chart` 17 个 gzip 283 KB,从次级页回 `/` 要新下载 8 个共 gzip 339 KB(`` 默认会预取,但未在浏览器里证实)。
## 根因
- 样式分叉:`TASK-cend-surfaces-claude-alignment-20260916` D9 决定次级页侧栏只读,T4.1 为了不把 `Home()` 的会话管理层上提而**另写了第二个组件**。D9 只说「不带操作」,没说「长得不一样」;分叉是实现手段的副作用,不是设计。
- 重复读取:没有共享 layout、没有缓存、首页出去是整页刷新,三件事叠加。
- 回首页慢:启动链串行且不复用侧栏已经拿到的账户与会话列表;「准备」阶段两个请求每次现拉。
## 决策记录(产品负责人 2026-09-16 拍板)
- **D1|侧栏只保留一个组件。** `AppSidebar` 收编 `AppNavRail`:会话操作回调与账户菜单改成**可选**,不传就渲染成只读(行是纯链接、页脚是去 `/` 的链接)。`app-nav-rail.tsx` 删除。D9「次级页不带写操作」**维持**,只是实现方式从「第二个组件」改成「同一组件的只读模式」。
- **D2|四个次级路由共享一个外壳实例。** 用路由组 `app/(secondary)/` 的 layout 挂 `SidebarProvider + AppSidebar`,页面只保留 46px 顶栏与正文。路由之间跳转不重挂侧栏、不重拉列表。
- **D3|首页进次级页改客户端跳转。** `leaveChat()` 的 `window.location.assign` 换成 ``(保留 `persistLoginSessionReturn()`)。`/login` 维持硬跳转。
- **D4|会话列表与账户在内存里缓存,先用旧数据再后台刷新。** 缓存是模块级、按账户 id 键、同一标签页内有效、过期时间 60 s;`/` 上的会话写操作(新建 / 重命名 / 删除 / 归档 / 收藏)成功后写穿缓存或使其失效。不落 localStorage。
- **D5|折叠状态跨页记住。** 存 cookie(shadcn 的 `sidebar_state` 惯例)或 localStorage 二选一,进度记录写明选哪个;移动端抽屉状态不记。
- **D6|本单不动 `Home()` 的启动链。** 回首页的等待另开一单(`TASK-home-bootstrap-reuse`),前提是本单先落地让内存缓存能活过跳转。本单只要求「新建对话」链接保持 `` 且预取不被关掉。
## 硬红线
1. `./node_modules/.bin/tsc --noEmit` 0 错;`npm run lint` **0 error**。
2. 测试总数不得低于开工时 `origin/staging` 的实测;改既有断言必须写「原值 / 新值 / 原因」三栏。`tests/sidebar-contract.test.ts`(43 条)与 `tests/sidebar-state.test.ts`(5 条)是侧栏 CSS 与状态合同,不得删条。
3. `next build` 后 `/` 保持 `○ Static`;`/chart`、`/ephemeris` 的渲染标记改前改后不变;`/reports`、`/reports/[reportId]` 维持 `force-dynamic`。路由组不得改变任何 URL。四个路由的标记逐个列进进度记录。
4. `/` 首屏 gzip ±2%(口径沿用 `PROGRESS-cend-ui-r1-20260916.md`:`.next/static` 下 css chunk gzip 合计,另附 `/` HTML 引用的 js chunk gzip 合计)。次级页体积改前改后列出。
5. `frontend/src/app/page.tsx` 不得增长;`Home()` 的 `useState` / `useRef` 数不得增长(`tests/home-shell-growth-contract.test.ts`)。本单对 `page.tsx` 的改动只允许:传给 `AppSidebar` 的 props 变化、写操作后调用缓存失效。
6. 次级页上的侧栏**不得**发起任何写操作接口(D9 维持)。只读模式下 `AppSidebar` 不得 import `page.tsx` 的任何 hook。
7. 揭幕后不得出现 spinner / 骨架 /「正在加载」。缓存未命中时侧栏显示现有静态空态文案(`sidebar-empty`),不是骨架。
8. 不改 `useConversationScrollAnchor`、`ChatComposer`;不动 BUG-698 的 `@supports (height: 1dvh)` 写法;不动报告盘面 grid。
9. 改 UI 的同一提交内更新 `frontend/DESIGN.md`(「The rail is read-only」一节改写成「只读模式」,删掉 `.nav-rail-*` 的描述)。
10. 不顺手升级依赖、不顺手修不在本单里的 warning。
## 任务分解
### T1 · `AppSidebar` 收编 `AppNavRail`(D1)
- `AppSidebarProps` 拆成两组:`sessions / activeSessionId / account / pathname`(两边都要)与 `controls?: { sessionControls, onNewChat, onSelectSession, onAccountMenuOpenChange, accountTriggerRef, onOpenProfile … }`(只有 `/` 传)。`controls` 缺省时:
- 四个导航项:新建对话是 ``,三个页面项是 `` 并按 `pathname` 打 `data-active`(沿用 `report-nav-button` 现有选中态)。
- 会话行:仍渲染 `SidebarSessionRow` 的**同一套标记**(`.session-row > .session-main`),但 `.session-main` 是 ``,不渲染菜单按钮;`grid-template-columns` 在只读模式下不留 44px 空列(加 `data-readonly` 属性切换,不新造类名)。
- 页脚:同一 `.profile-trigger` 标记,渲染成 ``,不带 chevron、不带菜单。
- 删除 `app-nav-rail.tsx`、`globals.css` 里 `.nav-rail-row` / `.nav-rail-identity` 段。
- `use-nav-rail.ts` 改名为 `use-sidebar-data.ts`(或等价),加 D4 的缓存(见 T3)。
**验收标准**
- `grep -rn "AppNavRail\|nav-rail" frontend/src` 为 0。
- `/` 与 `/chart` 的侧栏 DOM 结构除了「菜单按钮 / chevron / 账户菜单」三处之外逐节点相同;进度记录贴两页 `document.querySelector('[data-sidebar="sidebar"]').outerHTML` 的 diff(脱敏会话标题)。
- 只读模式下 `document.querySelectorAll('[data-sidebar="sidebar"] button')` 只剩折叠触发器。
- `sidebar-contract.test.ts` 全绿,不删条;新增断言:只读模式会话行有 `.session-main`、无 `.session-menu-trigger`、无 44px 空列。
### T2 · 路由组 layout 承载外壳(D2)
- 新建 `app/(secondary)/layout.tsx`(client component):`SidebarProvider + AppSidebar(只读) + SidebarInset`,`children` 是页面。
- 把 `app/chart`、`app/ephemeris`、`app/reports` 移进 `app/(secondary)/`;`page.tsx` 内容与 `metadata`、`dynamic` 声明原样保留。
- `SecondaryShell` 拆成只剩顶栏的 `SecondaryHeader({ title, note, actions })`,8 处调用改名;`SidebarTrigger` 仍在顶栏里(provider 由 layout 提供)。
- `../site-styles` 的 import 移到 layout 一处。
**验收标准**
- 四个 URL 不变;`next build` 路由表四个标记与开工时一致(红线 3)。
- 在 `/chart` → `/ephemeris` → `/reports` 之间跳转,Network 面板里**没有** `/api/sessions`、`/api/account` 请求(缓存未过期时);这条做不了自动化就写进 `docs/testing/` 清单,并用 `tests/` 里的渲染测试证明 layout 只挂一次(记录 `useSidebarData` 的 effect 调用次数)。
- `/reports/[reportId]` 的打印样式(`media="print"` 隐藏侧栏,见 `DESIGN.md` 「Print inside the shell」)在 layout 化之后仍生效,进度记录写明验证方式。
### T3 · 列表与账户缓存 + 首页写穿(D4)
- 模块级 `Map`,`useSidebarData()` 先同步读缓存渲染,再后台刷新;60 s 内不重拉。
- `/` 上 `useSessionManagement` 的新建 / 重命名 / 删除 / 归档 / 收藏成功回调里调用 `invalidateSidebarCache()`(或直接写穿)。这是本单唯一允许碰 `page.tsx` / `use-session-management.ts` 的地方。
- 401 时清缓存并按现有 `signedOut` 分支降级。
**验收标准**
- 单测:命中缓存时不发 fetch;过期后发一次;写操作后下一次进次级页拿到的是新标题 / 新顺序(用 `vi.useFakeTimers` 推时间)。
- 在 `/` 重命名一个会话 → 进 `/chart`,侧栏立刻显示新标题(真机清单)。
### T4 · 首页进次级页改客户端跳转(D3)
- `app-sidebar.tsx` 的 `leaveChat()` 删除,三个页面项改 ``,`onClick` 里保留 `persistLoginSessionReturn()` 与移动端关抽屉。
- `onOpenReports` 这个已经没人用的 prop(当前解构成 `_onOpenReports`)一起删。
**验收标准**
- 点星盘 / 星历 / 我的报告不再整页刷新(Network 里没有 document 请求;真机清单)。
- `/login` 仍是硬跳转(`tests/chat-session-url.test.ts` 补一条 `persistLoginSessionReturn` 在跳转前被调用的断言)。
### T5 · 折叠状态持久化(D5)
- `SidebarProvider` 读写 cookie `sidebar_state`(或 localStorage,二选一写明),`defaultOpen` 由 layout / `page.tsx` 从存储读;SSR 首帧与客户端一致,不得闪一下。
- `tests/sidebar-state.test.ts` 补两条:收起后重挂仍收起;移动端不受影响。
**验收标准**
- `/` 收起侧栏 → 进 `/chart`,仍收起;反向同理。
### T6 · 文档与清单
- `frontend/DESIGN.md`:侧栏一节改成「一个组件、两种模式」,写明只读模式少哪三样。
- `docs/testing/sidebar-unify-20260916.md`:真机清单(跳转无 document 请求、跨页无 `/api/sessions`、重命名后立刻可见、折叠状态跨页、打印隐藏侧栏、移动端抽屉)。
- `docs/BUG_HISTORY.md`:BUG-744 侧栏两套标记结构导致次级页会话行 / 页脚样式分叉;BUG-745 首页进次级页整页刷新 + 次级页外壳逐页重挂导致每次跳转重拉会话列表;BUG-746 折叠状态不持久。三条都要有针对性回归测试才能写 `resolved`。
- `CHANGELOG.md` 一条。
## 让步顺序
1. T5 折叠持久化如果 SSR 首帧对不齐(闪一下),可以退到只在客户端 `useEffect` 后同步,进度记录写明并保留 `docs/testing/` 条目。
2. T3 的写穿如果撞 `page.tsx` 增长门禁,退到「写操作后只失效、不写穿」。
3. T2 若 `/reports/[reportId]` 的打印样式在 layout 化后失效且一时修不好,先让 `[reportId]` 留在路由组外、保留一份自己的外壳,其余三页照做,并在进度记录标 `blocked`。
4. 不得让步的:T1(一个组件)、T4(不整页刷新)、红线 5 / 6。
## 开工前置命令
```bash
git -C /workspace/Jyotisha status -sb | head -1
git -C /workspace/Jyotisha fetch origin --prune
git -C /workspace/Jyotisha worktree add -b codex/sidebar-unify-20260916 .worktrees/sidebar-unify-20260916 origin/staging
cd /workspace/Jyotisha/.worktrees/sidebar-unify-20260916/frontend && npm ci
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5 # 记基线:测试总数、fail 清单
npm run build 2>&1 | grep -E "○|ƒ|λ" | grep -E "^\s*[○ƒλ] /(chart|ephemeris|reports|$)" # 记四个路由标记
```
同一时间若有别的单在改 `frontend/src/app/page.tsx` 或 `globals.css`(当前状态板上:`TASK-home-state-lowering-batch2-20260916` 待领取),本单**后于**它合入;本单对这两个文件的改动都很小,rebase 即可。
## BUG 编号起点
开工时核对 `docs/BUG_HISTORY.md` 最大号;本单写作时最大号为 **BUG-743**,本单从 **BUG-744** 起。