Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
14 KiB
14 KiB
TASK · 侧栏统一成一个组件 + 次级页共享外壳 + 跳转不再整页刷新(2026-09-16)
分支:codex/sidebar-unify-20260916。纯前端,不动数据库、不动 Skill、不动 Python。
基线 commit
origin/staging = cfb41daf(feat(rectification): 出卡加精度门槛,补经历改成系统点名)。本单涉及的文件自 317e9f18 起没有变化,进度记录里按开工时的 git rev-parse origin/staging 重新写一次。
事故实证(行号按符号定位,会漂)
产品负责人在 staging 真机上的三条观察,逐条对到代码:
- 首页与三个次级页的侧栏长得不一样。
/用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里是<Link className="session-row nav-rail-row">直接套.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,折叠状态只活在内存里。
- 每次进次级页都重新读会话列表。
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,且无任何缓存。
- 从次级页回「新建对话」等很久。
AppNavRail里「新建对话」是<Link href="/">,客户端跳转本身没问题;慢在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,/chart17 个 gzip 283 KB,从次级页回/要新下载 8 个共 gzip 339 KB(<Link>默认会预取,但未在浏览器里证实)。
根因
- 样式分叉:
TASK-cend-surfaces-claude-alignment-20260916D9 决定次级页侧栏只读,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换成<Link>(保留persistLoginSessionReturn())。/login维持硬跳转。 - D4|会话列表与账户在内存里缓存,先用旧数据再后台刷新。 缓存是模块级、按账户 id 键、同一标签页内有效、过期时间 60 s;
/上的会话写操作(新建 / 重命名 / 删除 / 归档 / 收藏)成功后写穿缓存或使其失效。不落 localStorage。 - D5|折叠状态跨页记住。 存 cookie(shadcn 的
sidebar_state惯例)或 localStorage 二选一,进度记录写明选哪个;移动端抽屉状态不记。 - D6|本单不动
Home()的启动链。 回首页的等待另开一单(TASK-home-bootstrap-reuse),前提是本单先落地让内存缓存能活过跳转。本单只要求「新建对话」链接保持<Link>且预取不被关掉。
硬红线
./node_modules/.bin/tsc --noEmit0 错;npm run lint0 error。- 测试总数不得低于开工时
origin/staging的实测;改既有断言必须写「原值 / 新值 / 原因」三栏。tests/sidebar-contract.test.ts(43 条)与tests/sidebar-state.test.ts(5 条)是侧栏 CSS 与状态合同,不得删条。 next build后/保持○ Static;/chart、/ephemeris的渲染标记改前改后不变;/reports、/reports/[reportId]维持force-dynamic。路由组不得改变任何 URL。四个路由的标记逐个列进进度记录。/首屏 gzip ±2%(口径沿用PROGRESS-cend-ui-r1-20260916.md:.next/static下 css chunk gzip 合计,另附/HTML 引用的 js chunk gzip 合计)。次级页体积改前改后列出。frontend/src/app/page.tsx不得增长;Home()的useState/useRef数不得增长(tests/home-shell-growth-contract.test.ts)。本单对page.tsx的改动只允许:传给AppSidebar的 props 变化、写操作后调用缓存失效。- 次级页上的侧栏不得发起任何写操作接口(D9 维持)。只读模式下
AppSidebar不得 importpage.tsx的任何 hook。 - 揭幕后不得出现 spinner / 骨架 /「正在加载」。缓存未命中时侧栏显示现有静态空态文案(
sidebar-empty),不是骨架。 - 不改
useConversationScrollAnchor、ChatComposer;不动 BUG-698 的@supports (height: 1dvh)写法;不动报告盘面 grid。 - 改 UI 的同一提交内更新
frontend/DESIGN.md(「The rail is read-only」一节改写成「只读模式」,删掉.nav-rail-*的描述)。 - 不顺手升级依赖、不顺手修不在本单里的 warning。
任务分解
T1 · AppSidebar 收编 AppNavRail(D1)
AppSidebarProps拆成两组:sessions / activeSessionId / account / pathname(两边都要)与controls?: { sessionControls, onNewChat, onSelectSession, onAccountMenuOpenChange, accountTriggerRef, onOpenProfile … }(只有/传)。controls缺省时:- 四个导航项:新建对话是
<Link href="/">,三个页面项是<Link>并按pathname打data-active(沿用report-nav-button现有选中态)。 - 会话行:仍渲染
SidebarSessionRow的同一套标记(.session-row > .session-main),但.session-main是<Link href={sessionHref("", id)}>,不渲染菜单按钮;grid-template-columns在只读模式下不留 44px 空列(加data-readonly属性切换,不新造类名)。 - 页脚:同一
.profile-trigger标记,渲染成<Link href="/">,不带 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<accountId, { sessions, account, fetchedAt }>,useSidebarData()先同步读缓存渲染,再后台刷新;60 s 内不重拉。 /上useSessionManagement的新建 / 重命名 / 删除 / 归档 / 收藏成功回调里调用invalidateSidebarCache()(或直接写穿)。这是本单唯一允许碰page.tsx/use-session-management.ts的地方。- 401 时清缓存并按现有
signedOut分支降级。
验收标准
- 单测:命中缓存时不发 fetch;过期后发一次;写操作后下一次进次级页拿到的是新标题 / 新顺序(用
vi.useFakeTimers推时间)。 - 在
/重命名一个会话 → 进/chart,侧栏立刻显示新标题(真机清单)。
T4 · 首页进次级页改客户端跳转(D3)
app-sidebar.tsx的leaveChat()删除,三个页面项改<Link>,onClick里保留persistLoginSessionReturn()与移动端关抽屉。onOpenReports这个已经没人用的 prop(当前解构成_onOpenReports)一起删。
验收标准
- 点星盘 / 星历 / 我的报告不再整页刷新(Network 里没有 document 请求;真机清单)。
/login仍是硬跳转(tests/chat-session-url.test.ts补一条persistLoginSessionReturn在跳转前被调用的断言)。
T5 · 折叠状态持久化(D5)
SidebarProvider读写 cookiesidebar_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一条。
让步顺序
- T5 折叠持久化如果 SSR 首帧对不齐(闪一下),可以退到只在客户端
useEffect后同步,进度记录写明并保留docs/testing/条目。 - T3 的写穿如果撞
page.tsx增长门禁,退到「写操作后只失效、不写穿」。 - T2 若
/reports/[reportId]的打印样式在 layout 化后失效且一时修不好,先让[reportId]留在路由组外、保留一份自己的外壳,其余三页照做,并在进度记录标blocked。 - 不得让步的:T1(一个组件)、T4(不整页刷新)、红线 5 / 6。
开工前置命令
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 起。