Files
Jyotisha/docs/tasks/TASK-session-list-rebuild-20260921.md
T
Jesse_ChenandClaude Opus 5 f8d65e484d docs(tasks): 会话列表重建任务书(BUG-987~989)
校正会话被 messages<>'[]' 整类过滤掉、副标题与排序不同源、
新建复用旧空草稿。产品拍板副标题改最后活动时间、第一问前不落库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-21 16:11:04 +08:00

201 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK 会话列表重建:校正会话消失、显示时间与排序不同源、空会话落库
- 日期:2026-09-21
- 基线 commit:`0c3c9d6b`(`origin/staging`,与 `https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 一致)
- 分支:`codex/session-list-rebuild-20260921`(单分支串行,三个任务都动 `frontend/src/app/api/sessions/route.ts`,不得并行开两个 worktree)
- BUG 编号起点:**BUG-987**(开工时以 `docs/BUG_HISTORY.md` 实际最大号为准,当前最大 `BUG-986`)
- 关联记录:BUG-553、BUG-699、BUG-704、BUG-705、BUG-926、BUG-927、**BUG-928**、**BUG-929**、BUG-933
---
## 1. 事故实证
产品负责人 2026-09-21 15:56 在 `staging.jyotisha.chat` 的侧栏截图,列表只剩 6 条:
| 位置 | 标题 | 副标题显示 | 所在分组 |
| --- | --- | --- | --- |
| 1 | 今日节奏 · 9月18日 | 9月18日 08:01 | 最近 7 天 |
| 2 | 新对话(当前选中) | 9月17日 22:53 | 最近 7 天 |
| 3 | 9月16日 · 今日节奏 | **9月7日 13:42** | 最近 7 天 |
| 4 | 我下半年的运势怎么样 职业… | 9月16日 16:01 | 最近 7 天 |
| 5 | 深入看今日 | 9月9日 16:53 | 最近 30 天 |
| 6 | 深入看今日 | 9月9日 16:37 | 最近 30 天 |
三条现象:
1. **一条生时校正会话都没有。** 产品负责人 09-08 至 09-17 在 staging 跑过数十轮真实校正,全部不在列表里。
2. 显示的时间不单调(9/18 → 9/17 → 9/7 → 9/16),且 9/7 的行落在「最近 7 天」分组里(今天 9/21,7 天边界是 9/15)。
3. 点「新建对话」没有新建,落到了 9/17 那条旧「新对话」上。
三条都由同一个提交 `e4e73f56`(2026-09-17,`fix(web): 四个页面共用一份会话列表,空会话不入列`)引入。
### 1.1 实证 A:列表过滤把整类校正会话删掉(P0)
`frontend/src/app/api/sessions/route.ts`,`excludeEmptyConsultations()`:
```
// Empty consultations are `messages = []`. Rectification rows keep an opening
// turn, so excluding empty arrays leaves them in the list (BUG-928).
return query.not("messages", "eq", []);
```
注释第二句与代码事实相反。校正会话的 `chat_sessions.messages` **永远是 `[]`**,三条独立证据:
- `frontend/src/hooks/use-rectification-surface.ts`,`openRectificationSession()` 里构造 `merged: ChatSession` 时写死 `messages: []`(`sessionType: "birth_time_rectification"` 同一对象字面量内,符号定位:`const merged: ChatSession = {`)。
- `frontend/src/lib/chat-session-write-contract.ts`,`chatSessionCreateSchema` 的 `messages: z.array(chatMessageSchema).max(0)`——创建路径只接受空数组;`chatSessionWriteSchema` 上方注释写明 PATCH「parses this shape so old bundles are accepted, then the messages field is ignored rather than stored」。
- `frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql` 的 `touch_chat_session_from_rectification_case()` 只 `set updated_at = new.last_activity_at`,不碰 `messages`。校正的轮次存在 case 表里。
而客户端 `frontend/src/lib/session-list-filter.ts` 的 `isListedSidebarSession()` 明确放行:
```
if (session.sessionType === "birth_time_rectification") return true;
```
**两层规则相反,服务端先把行拿走了,客户端的放行永远轮不到执行。**
门禁没拦住的原因:`frontend/tests/chat-session-authority.test.ts` 只做源码正则断言
`assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/)`,从未拿真实 Postgres 行验证过「哪些行还在」。
深链 `?c=<uuid>` 仍能打开校正会话(`resolveBootstrapSessionSelection()` 回落 `urlAction: "lookup"` 走单条读取),但侧栏是唯一的发现入口,对用户等于历史全部丢失。
### 1.2 实证 B:显示的时间不是排序用的时间
- 显示:`frontend/src/lib/session-sidebar-row.ts` 的 `sessionSidebarSubtitle()` → `const created = session.createdAt || session.updatedAt;`
- 排序:`frontend/src/lib/session-groups.ts` 的 `sortSessions()` → `right.session.updatedAt - left.session.updatedAt`
- 分组:同文件 `recencyKeyFor(updatedAt, now)`
- 服务端排序:`route.ts` 的 `.order("updated_at", { ascending: false }).order("id", { ascending: false })`,游标 `sessionCursorFilter()` 同样按 `updated_at`
`e4e73f56` 之前 `SESSION_LIST_COLUMNS` 里没有 `created_at`,`createdAt` 回落到 `updatedAt`,两者恰好一致;该提交把 `created_at` 加进列之后就分叉了。
**排序链路本身没有缺陷**:第 3 行是 9/7 创建、9/16 晚上仍在使用的会话,`updated_at` 落在第 2 与第 4 之间,位置和分组都正确。用户看到的那一列数字不是排序键,所以列表读起来是乱的。
### 1.3 实证 C:第一问之前就落库,新建变成复用旧草稿
- `frontend/src/hooks/use-session-management.ts` 的 `startNewChat()`:无 `continuedFromSessionId` 时先 `findReusableEmptyConsultation(sessions)`,命中就 `setActiveSessionId(reusable.id)` 直接返回,**既不 bump `updated_at` 也不改 `created_at`**。
- `route.ts` 的 GET 在首页(无游标)时额外查一条 `session_type = 'consultation' and messages = []` 作为 `draft` 返回;`frontend/src/app/(app)/page.tsx` 的启动流程把它并进 `nextSessions`,所以这条空会话照常出现在侧栏,带着 9/17 的时间。
- `BLOCKED.md` 第 120 行「会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)」记录了当初的让步:任务书原本要求「第一问前才 `POST /api/sessions`」,因牵动 `?c=` 深链与刷新恢复而降级成「服务端过滤 + 复用空会话」。
---
## 2. 根因
一句话:**`e4e73f56` 用「`messages` 是否为空」当作「这条会话有没有内容」的判据,但这个判据只对咨询会话成立;同一提交又把副标题换成另一个时间字段,让列表的可见顺序与真实排序键脱钩。**
拆开三层:
1. **判据错位。** 「空会话」的真实定义是「用户还没开口」。咨询会话恰好可以用 `messages = []` 表达,校正会话不能——它的内容在 case 表里。服务端把一个只对一半数据成立的判据写成了全表过滤。
2. **两套过滤规则。** 服务端 `excludeEmptyConsultations()` 与客户端 `isListedSidebarSession()` 对同一个问题给出相反答案,且没有任何测试跨这两层对断。
3. **让步没收口。** BUG-928 让步掉「延迟落库」之后,留下的替代方案(复用空会话)没有处理复用时的时间语义,于是「新建」这个动作在用户眼里变成了「跳到一条旧记录」。
---
## 3. 决策记录
产品负责人 2026-09-21 就地拍板,以下两条**推翻既有记录**,执行方按本节执行,不得以旧记录为由拒改:
- **D1(推翻 BUG-929 的「副标题为上海时区创建时间」)**:侧栏副标题改为**最后活动时间**(`updatedAt`),与排序、分组完全同源。理由:BUG-929 当初引入创建时间是为了替代被删掉的 `uniquify` HH:MM 后缀(同日多条靠钟点区分),这个目的用最后活动时间同样达到;而与排序键分叉造成的「列表读起来是乱的」是更大的代价。BUG-929 的其余决定(标题格式「生时校正 · M月D日」「今日节奏 · M月D日」、不再追加 HH:MM、旧标题不批量改)**全部保留**。
- **D2(收掉 `BLOCKED.md:120` 的让步)**:「第一问之前不落库」本轮必须做完。点「新建对话」只在本地开一条,用户真正发出第一句话时才 `POST /api/sessions`。复用已有空会话的逻辑随之**删除**(`findReusableEmptyConsultation` 及其调用点);服务端 GET 的 `draft` 字段随之**删除**。
产品负责人明确不接受的替代方案(不要再提):副标题同时显示两个日期;把排序改成按创建时间;保留复用但只 bump 时间戳。
---
## 4. 硬红线
1. **不得用「某列为空」推断会话有没有内容。** 列表要不要显示一条会话,判据只能是「用户是否已经开口」,且该判据必须对两种 `session_type` 都成立。校正会话在任何情况下都不得被列表过滤掉——这是 BUG-987 的防复发。
2. **显示、排序、分组、游标四者必须用同一个时间字段**(`updated_at`)。T2 完成后 `SESSION_LIST_COLUMNS` 不得再含 `created_at`,让分叉在结构上不可能复发。
3. 打开已有会话仍不得改 `title` / `updated_at`(BUG-699、BUG-553 的防复发,不得因本轮改动被推翻)。
4. `frontend/src/app/(app)/page.tsx` 不得增长(AGENTS.md §6);新逻辑进 hook 或 `lib/`。当前行数开工时记录,交付时不得高于该值。
5. 不得再手写第二套会话列表数据源;`(app)/layout.tsx` 的 provider 是唯一来源(BUG-927 的防复发)。
6. 源码合同测试搬家时两端都要改(BUG-933 的教训):任何断言 `page.tsx` 或 `route.ts` 内容的测试,改了实现就必须同步断言。
7. 不得顺手升级依赖、不得顺手修不在本任务书里的 warning。
---
## 5. 任务分解
### T1|列表不得吞掉生时校正会话(BUG-987,P0,先做)
- 把 `excludeEmptyConsultations()` 的过滤条件从「`messages <> '[]'`」改成「**咨询会话且 `messages = '[]'`** 时才排除」,即校正会话一律保留。实现上是把条件收窄到 `session_type = 'consultation'` 这一支,而不是全表按 `messages` 过滤。
- 归档视图(`archived=1`)走同一条规则,不得出现「正常视图看不到、归档视图能看到」或反之。
- 删掉 `route.ts` 里那句与事实相反的注释,换成说明校正会话的内容不在 `messages` 列里。
- **验收标准:**
- 新增真实 Postgres 测试 `frontend/tests/database-session-list-visibility.test.ts`:插入 ①`messages = []` 的咨询、②`messages = []` 的 `birth_time_rectification`、③有内容的咨询、④归档的校正,断言列表查询返回 ②③、不返回 ①,归档视图返回 ④。**不得用源码正则代替**(这正是 BUG-987 漏网的原因)。无 Docker 时按 §7 让步顺序处理。
- `frontend/tests/chat-session-authority.test.ts` 第 16 行那条正则断言同步更新为新写法,并补一条「过滤条件必须带 `session_type` 限定」的断言。
- 新增跨层对断:服务端过滤规则与 `isListedSidebarSession()` 对「空咨询 / 校正会话 / 归档」三种输入给出相同答案。
- 产品负责人在 staging 侧栏能看到 09-08 以来的校正会话。
### T2|显示时间与排序同源(BUG-988)
- `sessionSidebarSubtitle()` 改用 `session.updatedAt`(D1)。
- `SESSION_LIST_COLUMNS` 去掉 `created_at`(硬红线 2)。去掉前先确认列表路径没有第二个 `createdAt` 消费者——已核实只有副标题在读;`frontend/src/lib/rectification-session-title-repair.ts` 走的是迁移路径,不经过这个列表。
- `frontend/src/lib/home-cloud-sync.ts` 的 `createdAt` 回落链保持不变(详情接口仍返回 `created_at`),不要顺手删。
- **验收标准:**
- `frontend/tests/session-groups.test.ts` 或新测试断言:对任意一组会话,副标题渲染出的时间序列与 `sortSessions()` 的输出顺序单调一致(构造一条「早创建、晚活动」的会话,断言它排在正确位置且副标题显示的是晚活动时间)。
- 源码合同断言 `SESSION_LIST_COLUMNS` 不含 `created_at`。
- 截图第 3 行那种情况(9/7 创建、9/16 活动)在 UI 上显示 9月16日,且位置不变。
### T3|第一问之前不落库(BUG-989,最后做,牵动面最大)
- `startNewChat()`:删除 `findReusableEmptyConsultation` 分支,改为**只在本地创建**会话对象并选中,不发 `POST /api/sessions`。
- 首次真正发送消息时(`send()` 路径)才落库:先 `POST /api/sessions` 建行,再走既有的 `append_consultation_question`。落库失败时保持现有的回滚语义(`setSessions` 撤销 + `requestError`),不得把用户刚打的字弄丢。
- `route.ts` 的 GET 删除 `draft` 查询与 `draft` 字段;`(app)/page.tsx` 删除 `readDraftConsultation` 的并入;`session-list-context.tsx` 删除 `draftRow`。
- `?c=<uuid>` 深链与刷新恢复:本地未落库的会话没有服务端身份,**不得**把它的 id 写进 URL(现在 `startNewChat` 里 `writeSessionUrl(nextSession.id, "push")` 会写)。落库成功后再写 URL。刷新时未落库的本地会话消失是可接受的(它本来就是空的),但不得因此报「该对话不存在或已被删除」。
- 清理历史遗留:本轮**不做**存量空会话的批量删除(数据清理另议),但要确认 T1 之后它们仍被列表过滤掉。
- 更新 `BLOCKED.md:120` 那条——按 AGENTS.md §4,解除后划掉而不是删除。
- **验收标准:**
- `frontend/tests/session-list-lifecycle.test.tsx`(或新测试)断言:点「新建对话」后没有发生 `POST /api/sessions`;发出第一句话后恰好发生一次;落库失败时本地会话被撤销且草稿文本仍在。
- 断言未落库的本地会话不写 `?c=`,落库成功后才写。
- 断言 `route.ts` 响应体不再含 `draft`、`session-list-context.tsx` 不再有 `draftRow`。
- 产品负责人在 staging 连点五次「新建对话」,侧栏不出现任何「新对话」行;发一句话后出现一行,副标题是今天。
---
## 6. 交付前必须全跑
- `cd frontend && ./node_modules/.bin/tsc --noEmit` → 0 错
- `npm run lint` → 0 error
- `npm test` → **全量**,不是定向。失败清单与基线 `0c3c9d6b` 逐条比对,条数不得低于基线(AGENTS.md §7.3)
- `npm run test:db` → 有 Docker 时必跑(T1 新增的是真实 Postgres 测试)
- `npm run build` → `/` 仍 `○ Static`;首屏 gzip 与基线相差在 ±2% 内
- 本轮不动 Python,不要求 `run_quality_gate.py`
进度记录写 `docs/tasks/PROGRESS-session-list-rebuild-20260921.md`,Bug 记录三条(987/988/989)写进 `docs/BUG_HISTORY.md`,用户可感知的变化写进 `CHANGELOG.md`,侧栏副标题口径变化同一提交内更新 `frontend/DESIGN.md`。
---
## 7. 让步顺序
按此顺序让步,每让一步都要在进度记录里写明让了什么、替代证据是什么:
1. **无 Docker** → T1 的 `database-session-list-visibility.test.ts` 跑不了:测试文件照写照提交,在 `BLOCKED.md` 记明,并补一条不依赖 Docker 的查询构造合同测试(断言过滤条件里出现 `session_type` 限定)作为替代证据。**不得因此不写那个真实 DB 测试。**
2. **T3 的第一问落库牵出预料外的 `?c=` / 刷新回归** → 允许把 T3 单独拆到后一轮,但 T1、T2 必须本轮交付,且拆分理由要写进进度记录和 `BLOCKED.md`。**T1 不得让步**。
3. **首屏 gzip 超 ±2%** → 在进度记录里给出原因,不得为了指标回退功能。
4. 任何让步都不得触碰 §4 硬红线 1、2、3。
---
## 8. 开工前置命令
```bash
cd /workspace/Jyotisha
git status -sb | head -1 # 确认没落在别人的分支上
git fetch origin --prune
git worktree add -b codex/session-list-rebuild-20260921 \
.worktrees/session-list-rebuild-20260921 origin/staging
cd .worktrees/session-list-rebuild-20260921/frontend
npm ci # 若 node_modules 缺失
./node_modules/.bin/tsc --noEmit # 记录基线
npm test 2>&1 | tail -30 # 记录基线失败清单与总条数
grep -n "" src/app/\(app\)/page.tsx | tail -1 # 记录 page.tsx 基线行数
```
开工时必读:
- `docs/BUG_HISTORY.md` 的 BUG-553、BUG-699、BUG-704、BUG-705、BUG-926~929、BUG-933(检索关键词:`会话列表`、`messages = []`、`空会话`、`updated_at`、`draft`)
- `BLOCKED.md` 第 120 行起那条
- `docs/tasks/TASK-session-list-single-source-20260917.md` 与其 `PROGRESS`(本单是它的返工)
- `AGENTS.md` §2、§3、§5、§6、§7
纯前端 + 一条 API route,不要求 `scripts/pre_work_check.py`(AGENTS.md §9 末段)。