Files
Jyotisha/docs/tasks/TASK-session-list-single-source-20260917.md
T

133 lines
18 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.
# 任务书 · 会话列表一处数据源、服务端排序修正、空会话不入列、标题规则重定(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 起**(上一单占 924925;开工时核对 `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` 搬到 provider401 时清空并标 `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-926929926 关联 packages 路由;927 关联 BUG-745928 关联 BUG-553929 关联 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` 单调递减。