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

17 KiB
Raw Blame History

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.tsxuse-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.tsxuse-nav-rail.ts.nav-rail-* 两段 CSS 全部删除;grep -rn "AppNavRail|nav-rail" frontend/src = 0
T2 路由组 layout 承载外壳 完成 新建 src/app/(secondary)/layout.tsxchart / ephemeris / reports 三个目录 git mv 进去;SecondaryShellSecondaryHeader(只剩 46px 顶栏),14 处调用改名;四个 URL 与四个渲染标记均未变
T3 列表与账户缓存 + 首页写穿 完成(取「失效」不取「写穿」,见下) src/lib/sidebar-data-cache.ts 模块级 Map<accountId, entry> + 60 s TTLuse-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.mddocs/testing/sidebar-unify-20260916.mddocs/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.tspage.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 从未调用),真正跳转走的是整页刷新。删掉后 routerpage.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 上移到 layoutSecondaryShell 拆剩顶栏并改名。「每个阶段都在外壳里、都恰好一次、没有裸 <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 …/>) 外面裹一层 SidebarProviderwithSidebarProvider 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 +1the 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 +1the (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 早一帧,没有任何收益。
  • 因此读取只能发生在挂载后——具体是 SidebarProviderready 那个 effect,也就是断点默认值原本就生效的同一帧。对客户端跳转(/ ↔ 次级页)没有任何闪动,因为 provider 要么不重挂、要么重挂时 localStorage 已可读。
  • 剩下的缺口只有一种情形:桌面用户把侧栏收起来之后整页加载(F5 或直接输入网址)。首帧是展开,effect 之后收起。这一下在基线上不存在,因为基线根本不记状态。已写进 BUG-746 的「已知缺口」段与真机清单 E-4,请产品实测后判断是否值得再开一单(真正的修法是服务端读 cookie,代价是 / 掉出静态)。
  • 同轮考虑并否决的两个绕法:useState 初始化里同步读 localStorage(客户端首帧与 SSR HTML 不一致,React 19 会报 hydration mismatchsuppressHydrationWarning 又会让 React 跳过属性修补);根 layout 里塞内联脚本改 <html> 属性(要把 .chat-app 的栅格改成跟 <html> 走,动的面比本单大得多)。

六、次级页体积增加的原因

红线 4 只约束 / 的首屏 gzip(+0.94%,通过),次级页要求「列出」。它们涨了 8.8% / 13.2%,原因是 D1 的直接代价:次级页原先加载的是一个精简的只读组件,现在加载的是整个 AppSidebar —— 里面包含 @base-ui/react/menuThemePreferenceMenuUserAvatar 和带菜单的 SidebarSessionRow,即便只读模式一个都不渲染,模块仍然在同一个 chunk 里被打进去。

这是「一个组件」与「次级页更小」之间的取舍,产品在 D1 已经拍板要前者。若日后要把这部分拿回来,可行的做法是把账户菜单弹层拆成独立组件、只在 controls 存在时动态加载;本单没有做,因为它会在 / 的页脚引入一个加载态,撞红线 7(揭幕后不得出现 spinner / 骨架)。建议作为独立一单评估。

七、红线逐条

红线 结论
1 · tsc 0 错、lint 0 error 0 / 0warning 118 → 115
2 · 测试总数不降;sidebar-contract(43) 与 sidebar-state(5) 不删条 3391 → 3399sidebar-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 → 36cap 36)、useRef 37 → 37cap 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 不传 controlsapp-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.tsxglobals.css 与几个 api/*/route.ts 的定点路径,均未移动),因此路由组不会打红 Python 合同(BUG-132 / BUG-736 那一类复发面已核对)。