`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
17 KiB
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 B(23 个) | 609,504 B(25 个) | ✅ +0.94% |
/chart 同口径 |
282,125 B(17 个) | 319,352 B(20 个) | ⚠️ +13.2%,原因见 §六 |
/ephemeris 同口径 |
318,825 B(19 个) | 346,838 B(21 个) | ⚠️ +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 / 0;warning 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 那一类复发面已核对)。