Files
Jyotisha/docs/tasks/PROGRESS-sidebar-unify-20260916.md
T
Jesse_ChenandClaude Fable 5.1 22edabbcd3 docs(tasks): 侧栏统一进度记录、BUG-744~746、真机清单与 CHANGELOG
`PROGRESS-sidebar-unify-20260916.md` 记基线与改后的 tsc / lint / 测试总数 /
失败清单 diff / 五个路由标记 / gzip,逐条列改过的既有断言、触发的让步顺序
(T5 第 1 条)、次级页体积增加的原因,以及无 Chrome 无登录态的环境缺口。
`docs/testing/sidebar-unify-20260916.md` 是七组可照做的真机条目。
状态板本单一行改「待验收」,执行分支 `codex/sidebar-unify-20260916`。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 11:23:43 +00:00

119 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PROGRESS · 侧栏统一成一个组件 + 次级页共享外壳 + 跳转不再整页刷新(2026-09-16
任务书:`docs/tasks/TASK-sidebar-unify-20260916.md`
分支:`codex/sidebar-unify-20260916`
工作树:`/workspace/Jyotisha/.worktrees/sidebar-unify-20260916`
开工基线:`git rev-parse origin/staging` = `062af26d`(任务书里写的 `cfb41daf` 之后又落了一条纯文档提交,本单实际从 `062af26d` 起)。交付前 `origin/staging` 又前进到 `302ff085`(纯文档),本分支已 rebase 到其上;`docs/tasks/README.md` 有一处状态板冲突,已按「本单那行取我方(待验收),另一单那行取上游」解开,未覆盖任何他人内容。
BUG 编号:开工核对 `docs/BUG_HISTORY.md` 最大号为 **BUG-743**,本单用 **BUG-744 / 745 / 746**,与任务书一致,无顺延。
## 一、基线 vs 改后
| 项 | 基线 `062af26d` | 改后 | 结论 |
| --- | --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | 0 错 | 0 错 | ✅ |
| `npm run lint` | 0 error / **118** warning | 0 error / **115** warning | ✅ 少 3 条(删掉的 `app-nav-rail.tsx``use-nav-rail.ts` 带走的,没有顺手修任何 warning) |
| `npm test` | tests **3391** · pass 3329 · fail **47** · skipped 15 | tests **3399** · pass 3337 · fail **47** · skipped 15 | ✅ 总数 +8,失败清单 `diff` **逐条一致**(见 §二) |
| `/` 渲染标记 | `○` Static | `○` Static | ✅ |
| `/chart` | `○` | `○` | ✅ |
| `/ephemeris` | `○` | `○` | ✅ |
| `/reports` | `ƒ` | `ƒ` | ✅ |
| `/reports/[reportId]` | `ƒ` | `ƒ` | ✅ |
| `.next/static` css chunk gzip 合计 | **40,266** B | **40,223** B | ✅ 0.11% |
| `/` HTML 引用的 js chunk gzip 合计 | **603,843** B23 个) | **609,504** B25 个) | ✅ +0.94% |
| `/chart` 同口径 | 282,125 B17 个) | 319,352 B20 个) | ⚠️ +13.2%,原因见 §六 |
| `/ephemeris` 同口径 | 318,825 B19 个) | 346,838 B21 个) | ⚠️ +8.8%,同上 |
gzip 口径沿用 `PROGRESS-cend-ui-r1-20260916.md``.next/static` 下全部 css chunk 的 gzip 字节合计;js 一列是预渲染 HTML 里 `/_next/static/chunks/*.js` 去重后逐个 gzip 求和。同机、同脚本、改前改后各测一次。
## 二、失败清单 diff
```
diff baseline-fails.txt after-fails.txt → 空(IDENTICAL
```
47 条全部是无 Docker 的既有环境缺口(真实 Postgres 迁移、部署 compose、staging 同步、RLS 合同等),与基线逐条相同。中途出现过 16 条新红,全部由本单造成、全部已处理,处理方式见 §四。
## 三、任务分解逐条
| 条目 | 结论 | 说明 |
| --- | --- | --- |
| **T1** `AppSidebar` 收编 `AppNavRail` | ✅ 完成 | `controls?: AppSidebarControls` 缺省即只读;`app-nav-rail.tsx``use-nav-rail.ts``.nav-rail-*` 两段 CSS 全部删除;`grep -rn "AppNavRail\|nav-rail" frontend/src` = **0** |
| **T2** 路由组 layout 承载外壳 | ✅ 完成 | 新建 `src/app/(secondary)/layout.tsx``chart` / `ephemeris` / `reports` 三个目录 `git mv` 进去;`SecondaryShell``SecondaryHeader`(只剩 46px 顶栏),14 处调用改名;四个 URL 与四个渲染标记均未变 |
| **T3** 列表与账户缓存 + 首页写穿 | ✅ 完成(取「失效」不取「写穿」,见下) | `src/lib/sidebar-data-cache.ts` 模块级 `Map<accountId, entry>` + 60 s TTL`use-sidebar-data.ts` 同步读、后台刷新;`use-session-management.ts` 五个写操作成功后 `invalidateSidebarCache()`401 清空 |
| **T4** 首页进次级页改客户端跳转 | ✅ 完成 | `leaveChat()` 只剩 `persistLoginSessionReturn()` + 关抽屉;三个页面项由 `NAV_PAGES` 渲染成 `<SidebarMenuLink href=…>`;死 prop `onOpenReports` 删除 |
| **T5** 折叠状态持久化 | ⚠️ 完成,但触发让步顺序第 1 条 | 选 **localStorage**,键 `sidebar_state`;整页加载时首帧仍可能闪一下,见 §五 |
| **T6** 文档与清单 | ✅ 完成 | `frontend/DESIGN.md``docs/testing/sidebar-unify-20260916.md``docs/BUG_HISTORY.md` BUG-744/745/746、`CHANGELOG.md` 一条、`docs/tasks/README.md` 状态板 |
### T3 为什么是「失效」不是「写穿」
任务书 T3 原话是「调用 `invalidateSidebarCache()`(或直接写穿)」,两者都在主方案内,**没有触发让步顺序第 2 条**。选失效的理由:`Home()` 持有的是完整的 `ChatSession`(带 messages、rectification 字段、chartProfile 等),只读侧栏要的是精简的 `SidebarSession`,写穿等于在两处各维护一份表示;而失效只多一次 GET,代价确定。落点全在 `use-session-management.ts``page.tsx` 一行没为这件事变过。
## 四、改过的既有断言(原值 / 新值 / 原因)
共 9 处。没有一条是削弱:每一条要么主语不变只换取证位置,要么额外补了反向断言。
| # | 文件 · 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- | --- |
| 1 | `sidebar-contract` · `leaves the chat document for chart, ephemeris, and reports` → 改名 `reaches chart, ephemeris and reports with links, not a document load` | `persistLoginSessionReturn(); window.location.assign(path)` + 三个 `leaveChat("/…")` 调用点 | 三项由 `NAV_PAGES` 渲染成 `<SidebarMenuLink>``leaveChat()` 只剩 `persistLoginSessionReturn()`;新增 `doesNotMatch(/window\.location/)` | D3/T4。原写法是**有意**的整页刷新,理由是「首页用 `history.pushState` 维护 `?c=``router.push` 离不开首页」;`<Link>` 没有这个限制。`?c=` 的保存一步没少,仍在跳转前。测试条数不变(改名不算删条) |
| 2 | `sidebar-contract` · `history renders recency group labels and a silent load-more sentinel` | `sessionControls.hasMore ? <div ref={loadMoreRef}` | `hasMoreSessions ? <div ref={loadMoreRef}` | `sessionControls` 在只读模式下不存在,改从可选的 `controls` 解构;`hasMoreSessions` 是派生的稳定布尔值,effect 依赖数组也用它,否则 exhaustive-deps 会要求整个对象、让 IntersectionObserver 每次渲染重建。哨兵行为不变 |
| 3 | `chat-navigation-a11y-contract` · `in-app destinations navigate client-side…` | `import { useRouter }` + `const router = useRouter()` + `onOpenReports={() => router.push("/reports")}` | `doesNotMatch(pageSource, /useRouter/)` + `doesNotMatch(pageSource, /onOpenReports/)` + 断言侧栏用 `<SidebarMenuLink className="report-nav-button">` 且不含 `window.location` | `onOpenReports` 从 D9 起就是死 prop(侧栏解构成 `_onOpenReports` 从未调用),真正跳转走的是整页刷新。删掉后 `router``page.tsx` 再无消费者。断言主语(站内目的地不得整页刷新)不变,且从「首页持有 router」升级成「侧栏用 Link」 |
| 4 | `personal-report-entry` · `entry is global in the sidebar and absent from the active session header` | `match(sidebarSource, /onOpenReports/)` + `match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/)` | `match(sidebarSource, /\{ href: "\/reports", label: "我的报告"/)` + 三条 `doesNotMatch`(侧栏无 `onOpenReports`、页面无 `onOpenReports`、页面无 `useRouter` | 同 #3。入口仍然只在侧栏一处,主语不变 |
| 5 | `personal-report-view` · `the reader renders inside the app shell, in every phase` | `import { SecondaryShell }` + `<SecondaryShell title="个人报告">` 计数 | `import { SecondaryHeader }` + `<SecondaryHeader title="个人报告" />` 计数 | D2 把 provider + 侧栏 + inset 上移到 layout`SecondaryShell` 拆剩顶栏并改名。「每个阶段都在外壳里、都恰好一次、没有裸 `<main>`」一字未改 |
| 6 | `chart-page-view` · `sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的报告` | 标签写在 `<SidebarHeader>` JSX 里,用 `header.indexOf(">星盘<")` 量顺序;`leaveChat("/chart")` / `leaveChat("/ephemeris")` | 顺序改在模块常量 `NAV_PAGES` 里量,另断言这一段仍排在「新建对话」之后;三个 href/label 对逐条断言;新增 `doesNotMatch(/window\.location\.assign/)` | T1 要求三项在两种模式下逐字一致,写两遍必然再分叉,因此收进常量。主语(新建对话 → 星盘 → 星历 → 我的报告,且「我的报告」未改名)不变 |
| 7 | `chart-page-view` · `the chart page shell is visible before the natal chart arrives` | `match(markup, /新建对话/)`(在页面组件产物里断言侧栏第一项) | `match(markup, /data-sidebar="trigger"/)`;侧栏本体改由新增的 layout 合同断言 | D2 之后侧栏不再由页面组件渲染,页面产物里本就不该有它。主语(这一页不靠一次性「返回对话」链接回去)不变,覆盖没有减少——新增的 layout 测试比原断言更强 |
| 8 | `chart-page-view` / `ephemeris-page` 的 render 辅助 | `renderToStaticMarkup(<ChartPageView …/>)` / `(<EphemerisView …/>)` | 外面裹一层 `SidebarProvider``withSidebarProvider` | provider 从每页各一份上移到 layout,页面组件自己不再自带,而顶栏里的 `SidebarTrigger` 仍要读它。改的是测试挂载环境,不是断言主语 |
| 9 | `chart-view-route` / `ephemeris-page` / `stale-client-recovery` 的源码路径 | `../src/app/{chart,ephemeris,reports}/page.tsx` | `../src/app/(secondary)/{…}/page.tsx` | 路由组括号不进 URL`/chart` 等一字未改;改的只是源码位置 |
### 新增测试(+8 条,总数 3391 → 3399
- `tests/sidebar-data-cache.test.ts`(新文件,4 条):命中缓存不重拉 / 过期后重拉一次 / 写操作后拿到新标题(并逐条锁住五个写路径的调用点)/ 双账户不串 / 只存内存 / 401 清空。
- `tests/sidebar-state.test.ts` +2:收起后重挂仍收起(含无存储、脏值、存储不可用三种降级);移动端不读不写且 `null` 存储不抛。
- `tests/sidebar-contract.test.ts` +1`the same component renders read-only when / is not the one mounting it`——`controls?` 可选、只读行带 `.session-main`、只读行无 `session-menu-trigger``.session-row[data-readonly="true"]` 不留 44px 列、页脚是 `.profile-trigger` 链接。
- `tests/chart-page-view.test.tsx` +1`the (secondary) layout mounts one read-only sidebar for all four routes`——`<SidebarProvider>` 恰好一次、不传 `controls`、数据 hook 只发两个 GET 且不含任何写方法。
## 五、让步顺序
**触发了第 1 条**(T5 折叠持久化 SSR 首帧对不齐),没有触发第 2、3 条。
- 选的是 **localStorage**,不是 cookie。理由是硬约束而不是偏好:`/``/chart``/ephemeris` 三条路由都是 `○ Static`,在服务端读 cookie 会让它们一起掉出静态渲染,直接违反红线 3。cookie 若只在客户端读,则不比 localStorage 早一帧,没有任何收益。
- 因此读取只能发生在挂载后——具体是 `SidebarProvider``ready` 那个 effect,也就是断点默认值**原本就生效**的同一帧。对客户端跳转(`/` ↔ 次级页)没有任何闪动,因为 provider 要么不重挂、要么重挂时 `localStorage` 已可读。
- 剩下的缺口只有一种情形:桌面用户把侧栏收起来之后**整页加载**(F5 或直接输入网址)。首帧是展开,effect 之后收起。这一下在基线上不存在,因为基线根本不记状态。已写进 BUG-746 的「已知缺口」段与真机清单 E-4,请产品实测后判断是否值得再开一单(真正的修法是服务端读 cookie,代价是 `/` 掉出静态)。
- 同轮考虑并否决的两个绕法:`useState` 初始化里同步读 localStorage(客户端首帧与 SSR HTML 不一致,React 19 会报 hydration mismatch`suppressHydrationWarning` 又会让 React 跳过属性修补);根 layout 里塞内联脚本改 `<html>` 属性(要把 `.chat-app` 的栅格改成跟 `<html>` 走,动的面比本单大得多)。
## 六、次级页体积增加的原因
红线 4 只约束 `/` 的首屏 gzip(+0.94%,通过),次级页要求「列出」。它们涨了 8.8% / 13.2%,原因是 D1 的直接代价:次级页原先加载的是一个精简的只读组件,现在加载的是**整个** `AppSidebar` —— 里面包含 `@base-ui/react/menu``ThemePreferenceMenu``UserAvatar` 和带菜单的 `SidebarSessionRow`,即便只读模式一个都不渲染,模块仍然在同一个 chunk 里被打进去。
这是「一个组件」与「次级页更小」之间的取舍,产品在 D1 已经拍板要前者。若日后要把这部分拿回来,可行的做法是把账户菜单弹层拆成独立组件、只在 `controls` 存在时动态加载;本单没有做,因为它会在 `/` 的页脚引入一个加载态,撞红线 7(揭幕后不得出现 spinner / 骨架)。建议作为独立一单评估。
## 七、红线逐条
| 红线 | 结论 |
| --- | --- |
| 1 · `tsc` 0 错、`lint` 0 error | ✅ 0 / 0warning 118 → 115 |
| 2 · 测试总数不降;`sidebar-contract`(43) 与 `sidebar-state`(5) 不删条 | ✅ 3391 → 3399`sidebar-contract` 43 → **44**(只改写、只新增,未删);`sidebar-state` 5 → **7** |
| 3 · `/``○ Static`;其余四路由标记不变;路由组不改 URL | ✅ 五个标记逐个比对一致(见 §一);四个 URL 未变 |
| 4 · `/` 首屏 gzip ±2% | ✅ CSS 0.11%JS +0.94%;次级页已列出并说明 |
| 5 · `page.tsx` 不增长;`Home()``useState` / `useRef` 不增长 | ✅ 行数 1838 → **1837**(净删);`useState` **36 → 36**cap 36)、`useRef` **37 → 37**cap 37);`home-shell-growth-contract` 全绿。对 `page.tsx` 的改动只有两处:`AppSidebar` 的 props 形状(散 props 收进 `controls`)、删掉 `useRouter` 与死 prop `onOpenReports` |
| 6 · 次级页侧栏不发写接口;只读模式不 import `page.tsx` 的 hook | ✅ `use-sidebar-data.ts` 只有两个 GET,合同测试禁止 `method: "POST\|PATCH\|PUT\|DELETE"`layout 不传 `controls``app-sidebar.tsx` 不 import 任何 `page.tsx` 的 hook(合同测试仍禁止 `fetch(` / `/api/` |
| 7 · 揭幕后不得出现 spinner / 骨架 | ✅ 缓存未命中时仍是既有静态文案 `sidebar-empty`(「对话列表读取中」/「暂无对话…」/「登录后可以看到你的对话」);合同测试禁止 hook 里出现 `skeleton\|Spinner` |
| 8 · 不改 `useConversationScrollAnchor` / `ChatComposer` / BUG-698 的 `@supports` / 报告盘面 grid | ✅ 四者一字未动 |
| 9 · 同提交更新 `DESIGN.md` | ✅ 「Secondary page shell」整节重写成「一个组件、两种模式 + 只读少哪三样」,删掉 `.nav-rail-*` 描述;「Sidebar shell」的 **State** 一行按 T5 重写;报告打印一段的「nav rail」改「sidebar」 |
| 10 · 不升依赖、不顺手修 warning | ✅ `package.json` / `package-lock.json` 未动;warning 的减少全部来自删文件 |
## 八、环境缺口(不得写成通过)
| 项 | 缺什么 | 替代证据 |
| --- | --- | --- |
| Network 面板确认跳转无 document 请求 | 无 Chrome、无登录态 | 源码合同:侧栏禁止 `window.location`,三项是 `<SidebarMenuLink href>`;真机清单 §A |
| 跨页无 `/api/sessions` / `/api/account` | 同上 | layout 合同锁 `<SidebarProvider>` 恰好一次 + `useSidebarData()` 只在 layout 调用;缓存单测覆盖「命中不发 fetch」。真机清单 §B |
| 两页侧栏 DOM outerHTML 逐节点 diff | 同上(且任务书要求脱敏真实会话标题,本地无数据) | 同一个组件、同一份 `SidebarSessionRow`、同一份 CSS,差异面由 `sidebar-contract` 的只读合同锁死;真机清单 §D 给了可照做的 `copy(...)` 步骤 |
| 重命名后进 `/chart` 立刻可见 | 同上 | 单测锁五个写路径都调 `invalidateSidebarCache()`;真机清单 §C |
| 折叠状态跨页 / 整页刷新是否闪 | 同上 | 纯函数单测 + provider 源码断言;真机清单 §E(含第 4 条专门记录闪动) |
| `/reports/[reportId]` 打印仍隐藏侧栏 | 无 Chrome | `REPORT_SHELL_PRINT_CSS` 命中的五个选择器(`.chat-app``.chat-panel``[data-slot='sidebar-inset']``[data-slot='sidebar']``.chat-header`)在 layout 化之后一个不少、层级关系也没变——原先 `SecondaryShell` 渲染的正是这同一串,只是位置从组件内挪到了 layout;`printing a report inside the shell drops the chrome and its height lock` 合同测试仍绿。真机清单 §F |
| staging 部署核对 | 未推送(按分工只交本地分支,验收由主会话做) | 无 |
## 九、Python 侧
本轮零 Python 改动,未动 `scripts/``tests/`(Python)。核对过没有任何 Python 测试或脚本枚举 `frontend/src/app` 的路由集合(`grep -rn "src/app" --include=*.py` 只命中 `page.tsx``globals.css` 与几个 `api/*/route.ts` 的定点路径,均未移动),因此路由组不会打红 Python 合同(BUG-132 / BUG-736 那一类复发面已核对)。