Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
18 KiB
任务书 · 会话列表一处数据源、服务端排序修正、空会话不入列、标题规则重定(2026-09-17)
0. 基线
- 基线 commit:
eea90926(origin/staginghead)。串行在TASK-rectification-session-composer-guard-20260917.md之后:那一单改use-session-management.ts的回退路径,本单从它合入后的 staging 起分支,开工时以当时的origin/staging为准。 - 分支:
codex/session-list-single-source-20260917,git worktree add -b codex/session-list-single-source-20260917 .worktrees/session-list-single-source-20260917 origin/staging。 - 范围:
frontend/src/lib/db/local-postgres-client-core.ts(排序)、api/sessions两个路由、frontend/src/app/路由组重排(page.tsx与(secondary)/*移入(app)/)、新建lib/session-list-context.tsx/hooks/use-session-list.ts、hooks/use-session-management.ts、删除lib/sidebar-data-cache.ts/hooks/use-sidebar-data.ts、components/app-sidebar.tsx/components/sidebar-session-row.tsx、lib/agent-reply.ts的标题函数、lib/home-profile.ts的侧栏标题/副标题、lib/session-groups.ts,以及page.tsx(只允许减行)。不动 Python、不动 Skill。T3 若选服务端过滤方案不动表结构;若产品另批清理迁移再动表并真跑npm run test:db。 - BUG 段:BUG-926 起(上一单占 924–925;开工时核对
docs/BUG_HISTORY.md最大号)。
1. 事故实证
产品负责人 2026-09-17 反馈:/(新建对话)、/chart、/ephemeris、/reports 四处的侧栏会话列表不是同一份,每换一页都重新读一次;列表内容还一直在变;排序和标题规则看不出哪个是哪个。
用产品负责人登录态只读核对 staging(GET /api/sessions?limit=50,不含身份信息):
| 项目 | 结果 |
|---|---|
| 返回顺序 | 严格按 id 降序,updated_at 不单调 |
nextCursor |
2026-09-03T07:09:23Z,<id>,按 updated_at 构造 |
首页 50 条里最新的 updated_at |
2026-09-17 01:35Z;当天 11:12Z 新建的校正会话不在首页 |
| 首页 50 条标题分布 | 「新对话」28 条、「生时校正」4 条、「9月8日 · 生时校正」2 条、「9月14日 · 生时校正」2 条、其余各 1 |
| 各接口耗时 | sessions 0.83 s、account 0.87 s |
代码定位(按符号):
- 服务端排序丢了第一键。
frontend/src/lib/db/local-postgres-client-core.ts的order(column, options)写this.ordering = { column, ascending },只保存最后一次调用;SQL 生成处if (this.ordering) sql += " order by …"只有一个键。api/sessions/route.tsGET链式调用.order("updated_at", desc).order("id", desc),实际只按id排。游标sessionCursorFilter却按updated_at, id过滤,于是首页不是最近 40 条、翻页与首页重叠或漏掉、每次进入看到的集合都不同。同一缺陷还吃掉了api/payment/packages/route.ts的.order("sort_order").order("created_at"),套餐顺序变成按创建时间。 - 两份数据源。
/由page.tsx启动 effect 自己拉fetchSessions()(默认 40 条)+ 详情,存进Home()的sessionsstate,再经visibleSessions(过滤掉空的普通会话)→sidebarSessions喂给侧栏;(secondary)/layout.tsx用useSidebarData()另拉/api/sessions?limit=40+/api/account,进sidebar-data-cache.ts的 60 s 模块缓存,不做空会话过滤。/在app/里不在(secondary)路由组,离开/即卸载Home(),回来重跑整个启动(账户、目录、列表、详情、entry-summary、今日星语);invalidateSidebarCache()在/的每次写之后清空次级页缓存。BUG-745 修掉的是次级页之间的重挂,/↔ 次级页之间没有修。 - 空会话堆积。
page.tsx启动时starterHomeLandingNeedsConsultation为真(落点缺失或是校正会话)就createSession+writeChatSession(create)立即落库;startNewChat也是点了就落库。用户从没发过问题的「新对话」因此在库里累积(首页 50 条里 28 条),/用visibleSessions把它们藏起来,次级页原样显示,两边列表自然不一样。 - 标题规则。
resolveSessionTitle:普通会话在第一次回答前叫「新对话」,之后取模型标题或问题前 14 字;校正/今日节奏用datedSessionTitle生成「M月D日 · 生时校正」;同名靠uniquifySessionTitle追加「HH:MM」(新建时的墙钟,不是会话时间)。旧数据还有无日期的「生时校正」。侧栏行只有标题一行,/的副标题只在他人盘时显示,次级页根本没有副标题字段。结果是一列「新对话 / 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14」。 - 服务端
updated_at口径(BUG-553:只由对话活动推进,元数据 PATCH 不写)仍在,不是本单问题;客户端sortSessions置顶 +updatedAt倒序也正确,只是服务端给的集合本身不对。
Bug 历史检索:BUG-553(列表只按置顶排)、BUG-557(标题守卫 0 行)、BUG-699(打开旧校正会话改今天标题)、BUG-744~746(侧栏统一、次级页重挂、折叠状态)防复发措施均在,本单四条都是新问题;BUG-745 的「每跳一次重拉列表」在 / ↔ 次级页方向属未覆盖,关联但非复发。
2. 根因
- 本地 PostgreSQL 兼容层的
order()不支持多键,服务端列表从来没有按最近活动排过;游标分页因此与排序不一致。 - 会话列表没有一个跨路由的所有者:
Home()与(secondary)各拉各的,/又是每次进入从零启动。 - 会话在用户还没说一句话时就落库,而两处列表对空会话的处理不一致。
- 标题只承载「叫什么」,不承载「哪一类、哪一天、谁的盘」,同名去重又用墙钟时间。
3. 决策记录
- 产品负责人 2026-09-17 授权:列表必须共用一份;排序与标题规则重构。以下具体口径由 Claude 定,产品负责人如不同意在验收时改:
- 排序:置顶在前;组内按
updated_at倒序;updated_at仍只由对话活动推进(BUG-553 不变)。服务端与客户端同一口径。 - 哪些会话入列:只列有内容的会话——普通会话至少一条消息;校正会话一律入列(它一打开就有开场轮)。当前正在用的空会话只在
/本地显示,不入服务端列表。 - 标题:普通会话 = 模型标题,否则第一问前 14 字;第一次回答落库前显示「新对话」但不入列。校正会话 = 「生时校正 · M月D日」;今日节奏 = 「今日节奏 · M月D日」——类别在前,日期在后,扫一眼能分类。同日多条不再加墙钟「HH:MM」,改为副标题区分。
- 副标题(两处侧栏都显示一行小字):
M月D日 HH:MM(会话创建时间,Asia/Shanghai)+「 · <盘主名>」(仅他人盘)。校正会话再补状态词:进行中 / 已交付 / 已结束(从 entry-summary 或列表字段取;取不到就不显示,不得猜)。 - 存量数据:旧标题不批量改(BUG-699 已做过一次校正会话标题修复,且手改标题不能动);只对未来新建生效。空会话不删,只是不入列;若产品负责人另批清理,另开迁移单。
- 排序:置顶在前;组内按
- 产品负责人 2026-09-17 拍板:侧栏外壳提到路由组 layout,首页也进去,侧栏在四个页面之间不卸载;不做模块级 store。这推翻 BUG-745 防复发里「layout 不传
controls」与TASK-sidebar-unify-20260916D1「次级页侧栏只读、管理操作留在首页」两条:管理操作里只依赖列表的部分(改名、置顶、归档、删除、翻页)上移到 provider,四页都能用;依赖首页状态的(新建、选会话、校正打开)仍由首页注册。BUG-553 / BUG-699 的防复发不动。 page.tsx不得增长(本单应明显减少);Home()的乐观更新与回滚层保留,只是列表 state 的所有者换成 provider。
4. 硬红线
- 兼容层
order()改多键后,所有既有单键调用行为不变;有 SQL 文本级测试。 - 服务端列表排序键与
sessionCursorFilter的键必须一致(updated_at desc, id desc),有测试锁住。 - 元数据 PATCH 仍不写
updated_at(BUG-553);打开已有会话不改title/updatedAt(BUG-699,session-open-preserves-identity.test.ts必须保持绿)。 app/下SidebarProvider只在(app)/layout.tsx出现一次(BUG-745 口径升级);侧栏源码不得出现window.location;/login、/admin*不得带侧栏。- 既有测试断言不得静默弱化;改断言写「原值 / 新值 / 原因」三栏。测试总数不低于开工时。
- 无 Docker 时
npm run test:db失败照基线记录,不得写成通过。
5. 任务分解
T1 兼容层多键排序 + 列表接口排序契约(BUG-926)
local-postgres-client-core.ts:ordering改为数组,order()追加;SQL 生成order by a desc, b desc。api/sessions/route.ts两处查询(分页与置顶)保持.order("updated_at", desc).order("id", desc);api/payment/packages/route.ts不改代码,验证顺序恢复为sort_order, created_at。- 验收:
frontend/tests/local-postgres-query-value.test.ts(或新建local-postgres-order.test.ts):两次order()生成的 SQL 含order by "updated_at" desc, "id" desc;单次order()输出不变。frontend/tests/session-cursor.test.ts或chat-session-authority.test.ts加断言:列表路由源码的排序键序列与sessionCursorFilter的键一致。- 有 Docker 时
npm run test:db加一条:插入三条不同updated_at的会话,GET /api/sessions?limit=2返回最新两条且nextCursor翻页拿到第三条、无重叠。无 Docker 写BLOCKED.md。
T2 应用外壳提到路由组 layout,首页也进去,侧栏在四个页面之间不卸载(BUG-927)
产品负责人 2026-09-17 拍板:不做 store,做常驻外壳。侧栏、会话列表、账户摘要只有一个所有者,就是 layout;四个页面切换只换中间内容。
- 路由组:新建
frontend/src/app/(app)/;app/page.tsx移到(app)/page.tsx,(secondary)/chart|ephemeris|reports|reports/[reportId]移到(app)/下,(secondary)目录删除。URL 不变;/login、/admin*、error/not-found留在外面,不得带侧栏。每页自己的metadata/dynamic声明原样保留(reports的force-dynamic是页级的,不影响/的 Static)。 - 外壳 layout:
(app)/layout.tsx(client)=SessionListProvider+SidebarProvider+AppSidebar+SidebarInset。首页现在page.tsx里的<SidebarProvider escapeBlocked=…><main className="chat-app">…<AppSidebar …/>整段搬到这里;首页只剩SidebarInset里的内容。 SessionListProvider(新文件frontend/src/lib/session-list-context.tsx+frontend/src/hooks/use-session-list.ts)拥有:sessions: ChatSession[]与setSessions、分页游标、归档视图开关、账户摘要(侧栏页脚用)、signedOut。签入后每个标签页只拉一次列表;暴露一个readyPromise 给首页启动用。只依赖sessions+ fetch 的元数据操作(renameSession、togglePinnedSession、toggleArchivedSession、deleteSession、loadMoreSessions、toggleArchivedView)从use-session-management.ts搬到 provider;401 时清空并标signedOut。- 首页改造:
const [sessions, setSessions] = useState<ChatSession[]>([])改为从 provider 取;启动 effect 不再自己fetchSessions(),改await ready,其余(账户、目录、详情、落点选择、entry-summary、今日星语)不变。use-session-management.ts保留选会话、新建、换模型、校正会话延迟切换这些需要首页状态的部分,参数表相应缩短。 - 侧栏控制注册:首页独有的控制(新建对话、选会话、正在打开 / 打开失败的行状态、账户菜单开关、
escapeBlocked)通过 provider 的registerShellControls()在首页挂载时注册、卸载时清空;layout 把注册到的controls传给AppSidebar。另外三页没注册:会话行仍是Link到/?c=<id>,但改名、置顶、归档、删除、翻页这些 provider 自带的操作在四页都可用;新建对话在三页上是Link到/。高亮只在/上有。 - 删除:
hooks/use-sidebar-data.ts、lib/sidebar-data-cache.ts及tests/sidebar-data-cache.test.ts(改写成 provider 测试,不是删掉测试数量)。 - 合同测试改写(每条写三栏「原值 / 新值 / 原因」):
sidebar-contract.test.ts「the same component renders read-only when/is not the one mounting it」:原值断言次级 layout 不传controls;新值断言 layout 只传registerShellControls注册到的controls,未注册即只读。sidebar-contract.test.ts第 313 行附近对page.tsx的<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>正则:搬到对(app)/layout.tsx的断言,escapeBlocked来自注册值。- BUG-745 的防复发「layout 里
<SidebarProvider>恰好一次、且不传controls」改为「(app)/layout.tsx里恰好一次、app/其它地方零次;controls只来自注册」。chart-page-view、ephemeris-page、personal-report-view三个测试里对(secondary)路径的引用改到(app)。
- 验收:
next build:/仍○ Static;/chart、/ephemeris、/reports的渲染方式与基线一致。- 新测试:provider 挂载后
/api/sessions恰好一次;首页有 provider 列表时启动不再请求/api/sessions(mock fetch 计数);在/改名后 provider 里的行立即更新;401 清空。 page.tsx行数明显下降(外壳段 + 列表 state + 六个元数据操作的调用点都走了),进度记录写出前后行数。- 真机清单(
docs/testing/):DevTools Network 观察/→/chart→/ephemeris→/reports→/:/api/sessions与/api/account全程各一次;侧栏 DOM 节点在四页之间不重建(Elements 面板选中侧栏节点,切页后仍是同一节点);改名、置顶、删除后另外三页立即一致;/login与/admin无侧栏。
T3 空会话不入列,且延迟落库(BUG-928)
- 服务端:
api/sessionsGET排除session_type = 'consultation' AND messages = '[]'(保留校正会话);置顶查询同样处理。GET /api/sessions/<id>不变(详情仍可读)。 - 客户端:
startNewChat与启动时的落点会话改为本地创建、不立即落库,ChatSession加persisted: boolean;send()在撤回窗口结束、POST /api/consult之前若!persisted先writeChatSession(create)(失败则回滚并提示,沿用startNewChat现有回滚文案)。校正会话仍由/cases/open服务端创建,不受影响。 visibleSessions的过滤搬进 provider 的派生值(当前空会话只在/激活时可见),四页读同一个派生结果。- 让步:若延迟落库牵动
?c=深链、刷新恢复(pendingConsultationStorageKey)过多,可只做服务端过滤 + 启动时复用已有空会话而不再新建,把延迟落库写进BLOCKED.md。 - 验收:
chat-session-authority.test.ts:列表路由过滤条件存在;详情路由不过滤。- 新测试:连点三次「新建对话」不发
POST /api/sessions;发出第一问前恰好一次POST /api/sessions;?c=<本地未落库 id>刷新后不报「会话不存在」而是回到落点。 - 有 Docker:
test:db加一条空会话不出现在列表。
T4 标题与副标题规则重定(BUG-929)
agent-reply.ts:datedSessionTitle改为「<类别> · M月D日」;uniquifySessionTitle删除(同名允许,靠副标题区分);isGenericSessionTitle的前缀正则同步改成类别在前,并兼容旧「M月D日 · 生时校正」格式(旧标题仍判为 generic,行为不变)。- 列表接口增加
created_at(表里已有;只是没选出来)。toSidebarSession与sidebarSessions共用一个lib/session-sidebar-row.ts:title、subtitle = 「M月D日 HH:MM」+「 · 盘主」、校正状态词。sidebar-session-row.tsx副标题已支持,无需新组件;两处侧栏必须走同一个映射函数。 - 分组(今天 / 昨天 / 最近 7 天 / 30 天 / 更早)保留,按
updated_at。 frontend/DESIGN.mdSidebar shell 一节写明标题与副标题规则;frontend/docs/VOICE.md对照「生时校正 · 」「今日节奏 · 」措辞。- 验收:
agent-reply.test.ts更新(写三栏);新测试锁映射函数:同一行输入在/与次级页产出相同 title/subtitle;session-open-preserves-identity.test.ts保持绿。
T5 记录
docs/BUG_HISTORY.md新增 BUG-926~929(926 关联 packages 路由;927 关联 BUG-745;928 关联 BUG-553;929 关联 BUG-699);CHANGELOG.md;docs/tasks/PROGRESS-session-list-single-source-20260917.md;docs/tasks/README.md状态板行;BLOCKED.md(无 Docker 项)。
6. 让步顺序
T1 > T2 > T3(服务端过滤部分)> T4 > T3(延迟落库部分)。T1 一项独立可发。T2 内部若元数据操作上移牵动过大,允许第一步只做「路由组 + layout 常驻 + provider 拥有列表 + 首页注册全部控制」,元数据操作上移写进 BLOCKED.md 作第二步;但外壳常驻与列表单一所有者不得让步。
7. 开工前置命令
git fetch origin --prune
git log -1 --format='%h %s' origin/staging # 确认上一单已合入
git worktree add -b codex/session-list-single-source-20260917 .worktrees/session-list-single-source-20260917 origin/staging
cd .worktrees/session-list-single-source-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线失败清单与测试总数
grep -n "^## BUG-" ../docs/BUG_HISTORY.md | tail -1
8. 验收口径
tsc --noEmit 0 错;npm run lint 0 error;npm test 失败清单与基线逐条一致、新增测试全绿、总数不降;next build 后 / 仍 Static;首屏 gzip ±2%;page.tsx 行数 ≤ 开工时;有 Docker 则 npm run test:db 通过,无 Docker 写 BLOCKED.md。部署后用 DevTools 复核 T2 的「一次 /api/sessions」、侧栏节点不重建,与 GET /api/sessions 的 updated_at 单调递减。