Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
133 lines
18 KiB
Markdown
133 lines
18 KiB
Markdown
# 任务书 · 会话列表一处数据源、服务端排序修正、空会话不入列、标题规则重定(2026-09-17)
|
||
|
||
## 0. 基线
|
||
|
||
- 基线 commit:`eea90926`(`origin/staging` head)。**串行在 `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.ts` `GET` 链式调用 `.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()` 的 `sessions` state,再经 `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. 根因
|
||
|
||
1. 本地 PostgreSQL 兼容层的 `order()` 不支持多键,服务端列表从来没有按最近活动排过;游标分页因此与排序不一致。
|
||
2. 会话列表没有一个跨路由的所有者:`Home()` 与 `(secondary)` 各拉各的,`/` 又是每次进入从零启动。
|
||
3. 会话在用户还没说一句话时就落库,而两处列表对空会话的处理不一致。
|
||
4. 标题只承载「叫什么」,不承载「哪一类、哪一天、谁的盘」,同名去重又用墙钟时间。
|
||
|
||
## 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-20260916` D1「次级页侧栏只读、管理操作留在首页」两条:管理操作里只依赖列表的部分(改名、置顶、归档、删除、翻页)上移到 provider,四页都能用;依赖首页状态的(新建、选会话、校正打开)仍由首页注册。BUG-553 / BUG-699 的防复发不动。
|
||
- `page.tsx` 不得增长(本单应明显减少);`Home()` 的乐观更新与回滚层保留,只是列表 state 的所有者换成 provider。
|
||
|
||
## 4. 硬红线
|
||
|
||
1. 兼容层 `order()` 改多键后,所有既有单键调用行为不变;有 SQL 文本级测试。
|
||
2. 服务端列表排序键与 `sessionCursorFilter` 的键必须一致(`updated_at desc, id desc`),有测试锁住。
|
||
3. 元数据 PATCH 仍不写 `updated_at`(BUG-553);打开已有会话不改 `title` / `updatedAt`(BUG-699,`session-open-preserves-identity.test.ts` 必须保持绿)。
|
||
4. `app/` 下 `SidebarProvider` 只在 `(app)/layout.tsx` 出现一次(BUG-745 口径升级);侧栏源码不得出现 `window.location`;`/login`、`/admin*` 不得带侧栏。
|
||
5. 既有测试断言不得静默弱化;改断言写「原值 / 新值 / 原因」三栏。测试总数不低于开工时。
|
||
6. 无 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`。签入后每个标签页只拉一次列表;暴露一个 `ready` Promise 给首页启动用。只依赖 `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/sessions` `GET` 排除 `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.md` Sidebar 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. 开工前置命令
|
||
|
||
```bash
|
||
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` 单调递减。
|