Compare commits

...
Author SHA1 Message Date
jesse-ux db5b0a214b fix(web): 会话列表合同跟着外壳搬家,无活跃会话不再静默吞发送
Independent Staging Quality Gate / validate (push) Failing after 6m30s
Independent Staging Quality Gate / publish (push) Skipped
四条源码合同改到 (app)/layout 与注册载荷两端对断。列表只剩校正会话时普通输入框锁定,发送会打开校正或给出提示。
2026-09-17 23:06:01 +08:00
Jesse_ChenandClaude Opus 5 38cb81ff75 docs(tasks): 校正输入框守卫单通过,会话列表单未通过(门禁被 4 条陈旧合同堵住)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-17 14:45:48 +00:00
Jesse_ChenandClaude Fable 5.1 1c1d6817f5 docs(tasks): 回答定位开头修复单验收通过
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-17 14:37:11 +00:00
jesse-ux cc1a89802e fix(web): 无用户行钉顶、闲置不上留白,并修会话列表 tsc 两错
Independent Staging Quality Gate / validate (push) Failing after 6m27s
Independent Staging Quality Gate / publish (push) Skipped
2026-09-17 22:25:53 +08:00
Jesse_ChenandClaude Fable 5.1 b5e16b96f4 docs(tasks): 回答定位开头验收未通过,修复单 BUG-931/932
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-17 14:13:43 +00:00
jesse-ux 11c0028d59 fix(web): 回答从开头开始读,两个会话面同一套钉顶跟随
Independent Staging Quality Gate / validate (push) Failing after 6m26s
Independent Staging Quality Gate / publish (push) Skipped
2026-09-17 21:47:54 +08:00
28 changed files with 950 additions and 50 deletions
+6
View File
@@ -1,5 +1,11 @@
# BLOCKED # BLOCKED
## 会话列表:元数据操作未上移到 provider(2026-09-17,BUG-927 让步)
- **让步:** 任务书 T2 允许第一步只做「路由组 + layout 常驻 + provider 拥有列表 + 首页注册全部控制」。改名 / 置顶 / 归档 / 删除 / 翻页仍由首页注册,未挂载首页时侧栏只读。
- **现状:** 四个页面共用一份列表、切页不重拉已落地。星盘 / 星历 / 报告页不能改名或删除。
- **解除需要:** 把只依赖 `sessions` + fetch 的元数据操作搬进 `SessionListProvider`,四页都能用。另立单。
## 会话列表:空咨询延迟落库未做(2026-09-17,BUG-928) ## 会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)
- **让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。 - **让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## 2026-09-17 — 回答从开头开始读:发送后问题钉在顶部,回答向下生长,长出视口时显示跳到最新
主会话和生时校正发出新一轮后,视口停在这一轮的开头(你刚问的那一行,或校正里点选项后的新助手行),回答在下面往下长,不再跟着最后一个字往下跑。超过一屏时出现「跳到最新」,按下才跟到结尾。短回答下面会留一段空白,好让问题留在顶部。换到另一段对话仍先看到最新内容。Skill 版本不变。
## 2026-09-17 — 四个页面共用一份会话列表,切页不再重拉 ## 2026-09-17 — 四个页面共用一份会话列表,切页不再重拉
「星盘」「星历」「我的报告」和对话页共用同一条侧栏。打开一次之后,在这四个页面之间切换不会再重新去拉会话列表,改名也会马上在各页看到。没说过话的「新对话」不再进列表。新开的生时校正和今日节奏标题改成「生时校正 · 9月14日」这种类别在前的写法,侧栏多一行创建时间。Skill 版本不变。 「星盘」「星历」「我的报告」和对话页共用同一条侧栏。打开一次之后,在这四个页面之间切换不会再重新去拉会话列表,改名也会马上在各页看到。没说过话的「新对话」不再进列表。新开的生时校正和今日节奏标题改成「生时校正 · 9月14日」这种类别在前的写法,侧栏多一行创建时间。Skill 版本不变。
+100 -1
View File
@@ -774,6 +774,7 @@
- 相关记录:BUG-039、BUG-040 - 相关记录:BUG-039、BUG-040
- 复发自:无 - 复发自:无
- 修复版本:本次自动滚动修复提交 - 修复版本:本次自动滚动修复提交
- 2026-09-17 语义被 BUG-930 取代:生成中改为定位到本轮开头,不再贴底跟随。
## BUG-240 | 非评分回答绕过 Agent 并显示固定澄清模板 ## BUG-240 | 非评分回答绕过 Agent 并显示固定澄清模板
@@ -975,6 +976,7 @@
- 防复发:新增独立聊天滚动容器时必须同时提供末尾锚点,并覆盖乐观用户消息、生成状态和回答文本更新三类触发源。 - 防复发:新增独立聊天滚动容器时必须同时提供末尾锚点,并覆盖乐观用户消息、生成状态和回答文本更新三类触发源。
- 相关记录:BUG-046 - 相关记录:BUG-046
- 修复版本:待提交(本地可测) - 修复版本:待提交(本地可测)
- 2026-09-17 语义被 BUG-930 取代:生成中改为定位到本轮开头,不再贴底跟随。
## BUG-049 | 生时校正 Agent 提问悬空截断且回答缺少消息操作 ## BUG-049 | 生时校正 Agent 提问悬空截断且回答缺少消息操作
@@ -12094,7 +12096,7 @@
- 根因:`/` 不在 `(secondary)` 路由组,离开即卸载 Home;次级页另用 `useSidebarData` 再拉一遍。 - 根因:`/` 不在 `(secondary)` 路由组,离开即卸载 Home;次级页另用 `useSidebarData` 再拉一遍。
- 修复:四个页面进 `(app)` 路由组。一份 `SessionListProvider` 拉 `/api/sessions` 与 `/api/account`。首页不再自己 `fetchSessions`/`fetchAccount`。侧栏不随切页卸载。按任务书让步,改名/置顶/删除仍由首页注册,未注册时侧栏只读。 - 修复:四个页面进 `(app)` 路由组。一份 `SessionListProvider` 拉 `/api/sessions` 与 `/api/account`。首页不再自己 `fetchSessions`/`fetchAccount`。侧栏不随切页卸载。按任务书让步,改名/置顶/删除仍由首页注册,未注册时侧栏只读。
- 验证:`session-list-provider.test.ts`、`sidebar-contract.test.ts`、`chart-page-view.test.tsx`。 - 验证:`session-list-provider.test.ts`、`sidebar-contract.test.ts`、`chart-page-view.test.tsx`。
- 防复发:`app/` 里 `SidebarProvider` 只许出现在 `(app)/layout.tsx`。 - 防复发:`app/` 里 `SidebarProvider` 只许出现在 `(app)/layout.tsx`。外壳搬家必须同步搬走针对 `page.tsx` 的源码合同,且交付前必须跑全量 `npm test` 与 `tsc --noEmit`,不得只跑定向。
- 相关记录:BUG-745、BUG-926 - 相关记录:BUG-745、BUG-926
- 复发自:无 - 复发自:无
- 修复版本:待发布 - 修复版本:待发布
@@ -12130,3 +12132,100 @@
- 相关记录:BUG-699、BUG-557 - 相关记录:BUG-699、BUG-557
- 复发自:无 - 复发自:无
- 修复版本:待发布 - 修复版本:待发布
## BUG-930 | 主会话和生时校正回答落在结尾,读不到开头
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`useConversationScrollAnchor`、主会话 `send()`、生时校正面提交 / 点选项 / 开场、`JumpToLatestButton`
- 用户现象:问一个问题后视口钉在回答最后一个字上,要读回答得不断上滑找开头。生时校正长旁白同样把本轮开头推出视口。
- 触发条件:主会话或生时校正发出一轮,模型输出超过一屏。
- 根因:跟随语义是「贴底」。`anchored` 初值为真,`ResizeObserver` 在内容变高时把 `scrollTop` 设到 `scrollHeight`;`send()` 还会 `anchorToLatest()`。对短回答两者等价,对长回答则把开头推出视口。
- 修复:同一 hook 改为新一轮把本轮开头(用户行,或没有用户行时的新助手行)钉在滚动容器顶部,流式期间不再跟随。回答长出视口时用已有的「跳到最新」贴底并恢复跟随。末尾动态留白让短回答也能把开头留在顶部。切换会话仍先落底。取代 BUG-041 / BUG-048 的贴底语义。
- 验证:`chat-notice-and-scroll-contract.test.ts` 源码合同与行为测试;校正面 `pinLatestTurn` 三处源码断言。验收方用 Chrome 真实布局骨架页跑过 S1–S4(长历史发问钉顶、短历史留白、先上滑再发问、切会话落底)。S5/S6 见 BUG-931 / BUG-932。
- 防复发:跟随只经 `useConversationScrollAnchor`,跳到最新只经 `JumpToLatestButton`;`send()` 必须调 `pinLatestTurn`;校正面新轮只在提交文字、点选项、开场/无用户行助手到达时各 pin 一次。
- 相关记录:BUG-478、BUG-041、BUG-048、BUG-931、BUG-932
- 复发自:无
- 修复版本:待发布
## BUG-931 | 无用户行的一轮钉不住,随后退回贴底跟随
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`useConversationScrollAnchor` `applyTurnSpacer` / `pinLatestTurn`;生时校正点选项、开场、自动续轮
- 用户现象:点选项或开场后新助手行不在顶部;流式一开始视口又钉到最后一个字。
- 触发条件:本轮没有 `.message-user`,`pinLatestTurn()` 钉的是最后一条 `.message-assistant`。
- 根因:最后一条助手行同时是 turn head 和留白承载。`applyTurnSpacer` 用 `head.offsetHeight` 写 `--latest-turn-head-height`,量到的是「内容 + 上一帧留白」,公式自指后 `min-height` 坍成 0,`scrollTo` 被夹住。随后 `distance ≤ 96` 解除 `holdUnpin`,`anchored` 翻回真。
- 修复:head 与 `lastTurnTail` 是同一元素时 `--latest-turn-head-height` 写 `0px`,该行 `min-height` 等于整个视口。选这一路而不是先 `clearTurnSpacer` 再量,是因为清变量后仍要等一次布局,`offsetHeight` 在同一帧里不可靠。
- 验证:`chat-notice-and-scroll-contract.test.ts`「a turn with no user row…」。
- 防复发:无用户行时 spacer 头高必须是 0;pin 后增高不得把 `anchored` 翻回真。
- 相关记录:BUG-930
- 复发自:无
- 修复版本:待发布
## BUG-932 | 闲置会话上滑后子元素尺寸变化被写留白
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`useConversationScrollAnchor` `follow()` 非贴底分支
- 用户现象:在旧会话里上滑阅读时,窗口变宽、图片加载或思考块折叠会把最后一条助手行撑出一段空白,「跳到最新」也可能被顶出来。
- 触发条件:`anchored = false` 且本轮没有 `pinLatestTurn`,滚动容器子元素高度变化。
- 根因:`follow()` 在未贴底时无条件 `applyTurnSpacer`,把闲置上滑和「本轮已钉住」当成同一件事。
- 修复:只在 `pinnedHeadRef.current` 非空时写留白。`anchorToLatest()` 与 `resetKey` 变化时清 ref 和 CSS 变量。
- 验证:`chat-notice-and-scroll-contract.test.ts`「idle history does not write a spacer…」。
- 防复发:未 pin 的上滑不得写 `--conversation-viewport`;`latestBelowFold` 仍按尾部超出 96px 判定。
- 相关记录:BUG-930
- 复发自:无
- 修复版本:待发布
## BUG-933 | 四条源码合同没跟着外壳搬家,门禁卡住
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`chat-navigation-a11y-contract.test.ts`、`rectification-history-open-20260909.test.ts`、`rectification-surface-contract.test.ts`、`chat-session-url.test.ts`
- 用户现象:会话列表单实现已落地,但 staging 门禁 `npm test` 必红,今天的代码提交上不了线。
- 触发条件:`e4e73f56` 把 `<main className="chat-app">` 和侧栏 props 搬到 `(app)/layout.tsx`,启动改成 `sessionListReady`。
- 根因:四条合同仍按搬家前的 `page.tsx` 形状断言。被保护的性质都还在,是合同陈旧。执行方只跑了定向测试。
- 修复:四条按「原值 / 新值 / 原因」改写,分别断 layout 与 homepage 注册两端,不得删测试。
- 验证:上述四文件;全量 `npm test` 相对 `cc1a8980` 少这 4 条红。
- 防复发:见 BUG-927。
- 相关记录:BUG-927、BUG-745
- 复发自:无
- 修复版本:待发布
## BUG-934 | 今日星语 / 生时校正入口合同仍找已删除的卡片类名
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`tests/test_daily_and_rectification_entrypoints.py`
- 用户现象:Python 定向跑这两条必红。不在 `CORE_PYTEST_TARGETS`,不挡门禁。
- 触发条件:断言 `daily-starlanguage-card` / `birth-rectification-card`。
- 根因:`62903b7a` 把空状态改成问候 + 输入框 + 两枚 pill,类名已全仓删除。既有欠账,不是会话列表单引入。
- 修复:改为断言 `starter-entry` pill 与 `startDailyStarlanguageConsultation` / `openRectificationFromHomepage`,并断言旧类名不存在。
- 验证:`pytest tests/test_daily_and_rectification_entrypoints.py`。
- 防复发:入口形态变了必须改这条合同,不得把已删除的卡片类名当存在。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-935 | 列表只剩校正会话且无活跃会话时,普通输入框静默吞发送
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`page.tsx` `activeSession` 回退、`send()`、普通 `ChatComposer`
- 用户现象:账号里只有校正会话时,普通输入框能打字;发送没有任何提示,字消失。
- 触发条件:`activeSession` 为 `undefined`(列表全是校正,找不到非校正回退),校正面正在打开或打开失败。
- 根因:校正守卫把 `?? sessions[0]` 改成 `find(非校正)`。无活跃会话时 `activeRectificationSession` 为假,输入框可用;`send()` 因 `!currentSession` 直接 `return false`。
- 修复:`composerLocksAsRectification`:无活跃会话但列表有校正会话时按校正占位锁定。`send()` 无活跃会话时打开校正或提示「请先选一条对话」。
- 验证:`rectification-session-composer-guard.test.ts`。
- 防复发:无活跃会话不得静默吞发送;列表全是校正时普通输入框不得可用。
- 相关记录:BUG-924
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,36 @@
# PROGRESS · 回答定位到开头(2026-09-17)
工作树:`worktree-green-harbor-5be3`
分支:`worktree/green-harbor-5be3`
基线:`origin/staging` @ `5203f9f0`(任务书扩到校正面后)
本机 Windows。
Skill **未 bump**。未改 Python、迁移、Skill。
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| T1 hook 改语义 `pinLatestTurn` BUG-930 | 完成 | 签名不变。新一轮钉本轮开头(用户行;无用户行则新助手行)。`send()` 改 `pinLatestTurn()`。校正面三处:文字提交、点选项、开场/采用新助手行。 |
| T2 `latestBelowFold` | 完成 | 主会话 `jumpToLatestVisible` 与校正面按钮都改看 hook 的 `latestBelowFold`(末条底部超出视口 > 96px)。 |
| T3 末尾动态留白 | 完成 | 最后一轮助手行 `min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height))`,变量由 hook 写到滚动容器 `style`。未改 `.conversation` 的 `padding-bottom`,无新增 sticky。 |
| T4 思考块核实 | 完成 | 核实:`.consultation-thinking-report` 内 `ConsultationRunTimeline` 流式期间默认展开(`userOpen ?? live`),结算后折叠为一行;高度随步骤条数有界,不是逐 token 无界增长。未改。 |
| T5 记录 | 完成 | BUG-930;BUG-041 / BUG-048 末尾各补一行;CHANGELOG;DESIGN Answer start anchor;真人清单。 |
## 让步 / 偏离
- `resetKey` 变化先落底;若当时就在底部 96px 内则保持跟随,直到 `pinLatestTurn` 或上滑。未在落底后强制置假,否则历史会话消息尚未 hydrate 时会停在顶部。与「打开历史落在底部」一致。
- 本轮开头定位用 `getBoundingClientRect` 相对滚动容器,而不是字面 `row.offsetTop`(嵌套 `.message-entry` 时 `offsetTop` 不是容器坐标)。
- 无 jsdom 依赖;行为测试用最小 fake scroller 行使导出函数。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / 118 warning |
| 定向 | chat-notice-and-scroll / a11y / rectification-agentic-entry / starter-questions / home-shell / chat-panel-scroll-guard / birth-time-mobile-scroll **92 passed / 0 failed**;rectification-answer-choice **34 passed / 0 failed** |
| `page.tsx` 行数 | 开工 1830 → 现在 1830 |
| `next build` | Compile + TypeScript 通过;collect page data 在本机 Windows 因 Skill 运行时 `symlink EPERM` 失败(与本轮无关的环境缺口),未核对 `/` Static 与 gzip |
无登录态、无 Chrome:走查见 `docs/testing/consultation-answer-start-anchor-20260917.md`。
@@ -0,0 +1,26 @@
# PROGRESS · 回答定位到开头修复单(2026-09-17)
工作树:`worktree-green-harbor-5be3`
分支:`worktree/green-harbor-5be3`
基线:`origin/staging` @ `b5e16b96`
本机 Windows。
Skill **未 bump**。未改 Python、迁移、Skill、组件、CSS、`page.tsx`。
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 前置 tsc 两行 | 完成 | `sessionSidebarTitle(session, library)` 与测试第二参去掉。标题函数本来只吃 session,这两处是 `e4e73f56` 挡住门禁的 TS 错。 |
| F1 BUG-931 | 完成 | head 与 `lastTurnTail` 同一元素时 `--latest-turn-head-height` 写 `0px`。不采用先 clear 再量:同一帧 `offsetHeight` 仍含上一帧 min-height。 |
| F2 BUG-932 | 完成 | `follow()` 只在 `pinnedHeadRef` 非空时写留白;`anchorToLatest` / `resetKey` 清 ref 与变量。 |
| F3 记录 | 完成 | BUG-931/932;BUG-930 验证补 Chrome 骨架页;真机清单第 5 条拆成点选项 / 开场;状态板。 |
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错(清掉过期 `.next/types` 之后;原先两处 `Expected 1 arguments, but got 2` 已修) |
| `npm run lint` | 0 error / 117 warning |
| 定向 | chat-notice-and-scroll + chart-library-session **23 passed / 0 failed**(含 F1/F2 两条新测试) |
| `next build --webpack` | Compile + TypeScript 通过。本机 Windows 在 collect page data 时 Skill `symlink EPERM`,未核 `/` Static 与 gzip。Linux 门禁应能过类型检查。 |
@@ -0,0 +1,30 @@
# PROGRESS · 会话列表验收修复单(2026-09-17)
工作树:`.worktrees/session-list-single-source-fix-20260917`
分支:`codex/session-list-single-source-fix-20260917`
基线:`origin/staging` @ `38cb81ff`
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| F1 四条合同搬家 BUG-933 | 完成 | 不断性质,改断言到 layout + 注册载荷 |
| F2 Python 入口合同 BUG-934 | 完成 | 改为 `starter-entry` pill,断言旧卡片类名不存在 |
| F3 无活跃会话吞发送 BUG-935 | 完成 | `composerLocksAsRectification` + `send()` 给反馈 |
| F4 记录 | 完成 | BUG-927 防复发已补;元数据操作让步进 `BLOCKED.md` |
`page.tsx` 行数:开工 1824,结束 1824。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(118 warning,未动) |
| 四条目标合同 | `chat-navigation-a11y` / `rectification-history-open` / `rectification-surface-contract` / `chat-session-url`「home stays a client-read query」全绿 |
| F3 | `rectification-session-composer-guard` 新断言绿 |
| F2 | `pytest tests/test_daily_and_rectification_entrypoints.py` **4 passed** |
| 全量 `npm test` | 本机 Windows worktree **3221 / 3115 pass / 92 fail / 14 skip**。任务点名的 4 条不在失败清单。多出来的失败主要是:skill runtime `EPERM` 建 symlink、esbuild `spawn UNKNOWN`(junction 指到别的 worktree 的 esbuild)、无 Docker 的 database/deploy 套件。与验收机 `cc1a8980` 的 3457 / 39 红不可直接逐条对比。`creating and leaving a session keep the address bar in sync` 仍红:`deleteSession` 已走 `activateFallbackSession`(composer-guard 起),不是本单引入 |
| `next build --webpack` | webpack **Compiled successfully**;收集 `/api/birth-time-guide` 页数据时 Windows `EPERM` symlink 失败。`/` 是否 Static 未在本机核到。门禁 Linux 机应能过编译 |
未改排序、provider、路由组。`page.tsx` 1824 → 1824。
+5 -3
View File
@@ -106,7 +106,7 @@
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` | | `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` | | `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` | | `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` |
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 待验收 | `codex/rectification-session-composer-guard-20260917` | | `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 |
### 聊天主链路与首页 ### 聊天主链路与首页
@@ -131,8 +131,10 @@
| `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` | | `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` |
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` | | `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` | | `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 待验收(T1–T4;延迟落库让步) | `codex/session-list-single-source-20260917` | | `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 验收未通过 | `e4e73f56`(实现按任务书落地:多键排序 / `(app)` 路由组常驻外壳 / provider / 空会话不入列 + draft 复用 / 标题类别在前;两条让步任务书允许。**P1:4 条针对 `page.tsx` 的源码合同没跟着外壳搬家,`npm test` 在门禁里 `set -e` 下必红,staging 至今停在 `dc2f2a16`**。另:进度记录报「tsc 0 错」实为 2 错,只跑了定向测试。修复单 `TASK-session-list-single-source-fix-20260917.md`) |
| `TASK-consultation-answer-start-anchor-20260917.md` | — | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 待领取 | — | | `TASK-session-list-single-source-fix-20260917.md` | `PROGRESS-session-list-single-source-fix-20260917.md` | 验收修复单:F1 四条源码合同搬到 `(app)/layout.tsx` 两端对断(BUG-933);F2 两条陈旧 Python 入口断言(BUG-934,既有欠账);F3 无活跃会话时输入框静默吞发送(BUG-935);交付前必须跑全量测试 | 待验收 | `codex/session-list-single-source-fix-20260917` |
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
### 个人报告 ### 个人报告
@@ -0,0 +1,68 @@
# 验收修复单 · 回答定位到开头:无用户行的一轮钉不住、闲置会话被留白(2026-09-17)
## 0. 基线
- 验收对象:`11c0028d`(`origin/staging` head,执行方变基后快进推送)。任务书:`TASK-consultation-answer-start-anchor-20260917.md`。
- 分支:`codex/consultation-answer-start-anchor-fix-20260917`,从 `origin/staging` 起。
- 范围:只有 `frontend/src/hooks/use-conversation-scroll-anchor.ts` 与其测试、`docs/BUG_HISTORY.md`、`docs/tasks/`。不动 `page.tsx`、不动组件、不动 CSS。
- BUG 段:**BUG-931 起**(基线最大号 BUG-930)。
- **前置**:staging 自 `e4e73f56`(会话列表单)起 `tsc --noEmit` 与 `next build` 都红(`src/lib/session-sidebar-row.ts(30,41)`、`tests/chart-library-session.test.ts(89,42)`:`Expected 1 arguments, but got 2`),门禁过不了,`/api/health` 仍停在 `dc2f2a16`。那两处属于会话列表单的验收,另行处理;本单执行时若 staging 仍红,先在本分支顺手修这两行并在进度记录写明,否则本单也发不出去。
## 1. 验收实证
验收环境:Linux,Node 20.19,Google Chrome 151 无头(本机有 Chrome,真实布局;无登录态,所以用真实 hook + 与 `globals.css` 同参数的骨架页做场景,不是 staging 页面)。
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 2 错,**均继承自 `e4e73f56`**,本提交 0 新增 |
| `npm run lint` | 0 error / 117 warning |
| `npm test` | 3455 条,39 红;与父提交 `e4e73f56`(3450 条,39 红)逐条一致,**0 新红、+5 新测试全绿**。`e4e73f56` 自身比 `5203f9f0` 多 4 条红(会话列表单的事,另记) |
| `next build --webpack` | 编译通过,`Failed to type check`(同上两处继承错误);`/` Static 与 gzip 因此未核 |
| `page.tsx` 行数 | 1830 → 1830 |
| 文档 | BUG-930、BUG-041/048 各补指回行、CHANGELOG、DESIGN「Answer start anchor」、真机清单、PROGRESS 齐 |
| T4 思考块 | 执行方核实「按步骤条数有界、结算后折一行、未改」,与代码一致,接受 |
真实布局场景(容器 600px 高,`.message-list` 与 `.message` 用 `globals.css` 同一组 padding,最后一轮的 `min-height` 规则原样):
| 场景 | 结果 | 结论 |
| --- | --- | --- |
| S1 长历史、读者在底部、发问 | 钉顶后用户行距容器顶 16px;流式增高 6 步 `scrollTop` 纹丝不动;尾部超出 96px 后 `latestBelowFold` 为真;按「跳到最新」后贴底并跟随 | 通过 |
| S2 短历史(不溢出)、发问 | 留白让用户行到顶(16px),增高不动 | 通过 |
| S3 读者先滚到顶部再发问 | 钉顶 16px,增高不动 | 通过 |
| S4 切换会话 | 落底,`anchored` 为真 | 通过 |
| S5 闲置历史会话,读者上滑 400px,某条旧消息高度变化 30px | **最后一条助手行被写入留白**(底部从 936 变 1320,`--conversation-viewport` / `--latest-turn-head-height` 被写上),`scrollTop` 漂 30px | **P2,BUG-932** |
| S6 本轮没有用户行(校正点选项 / 开场 / 自动续轮):追加新助手行后 `pinLatestTurn()` | **钉不住**:新助手行停在距顶 480px;`--latest-turn-head-height` 被写成 740px;随后第一步增高 `anchored` 翻成真,视口回到贴底跟随 | **P1,BUG-931** |
S6 的机理(按符号):`resolveTurnHead` 正确选到新助手行;但 `applyTurnSpacer(element, head)` 用 `head.offsetHeight` 写 `--latest-turn-head-height`,而这个 head **就是**承载 `min-height: calc(viewport − head)` 的最后一条助手行——前一帧 `follow()` 的非贴底分支已经给它写过一次留白,于是量到的高度是「内容 + 留白」,留白公式自指后坍缩为 0,`scrollTo` 目标被 `scrollHeight` 夹住,头钉不到顶。接着 `measure()` 见 `distance ≤ 96` 解除 `holdUnpin`,`nextAnchorState` 判回 `anchored = true`,后续增高就是 BUG-930 之前的贴底行为。校正面用 `ChatMessageRow`,行也是 `.message-assistant`,所以真实页面同样命中。
S5 的机理:`follow()` 的非贴底分支对**任何** `anchored = false` 的容器都 `applyTurnSpacer`,不管有没有正在进行的一轮。读者在旧会话里上滑后,任何子元素尺寸变化(窗口宽度、图片、思考块折叠)都会给最后一条助手行补一段留白,还会把 `latestBelowFold` 顶成真。
## 2. 任务分解
### F1 头就是留白行时,留白按整视口算(BUG-931)
- `applyTurnSpacer(container, head)`:若 `head` 与 `lastTurnTail(container)` 是同一个元素(本轮无用户行),`--latest-turn-head-height` 写 `0px`,让该行 `min-height` = 整个视口;否则照旧写 `head.offsetHeight`。或者在量高之前 `clearTurnSpacer` 再量,两者取其一,进度记录写明选哪种及原因。
- `pinLatestTurn` 在 `applyTurnSpacer` 之后、`scrollTo` 之前必须能把 head 钉到 `offset − space4`;不得靠 `holdUnpin` 之外的新状态。
- 验收:
- 行为测试(fake scroller 或 jsdom)新增:容器内 `[u][a][u][a][a-new]`,`pinLatestTurn()` 后 `scrollTop === offset(a-new) − 16`,`--latest-turn-head-height === "0px"`;随后增高 3 步 `anchored` 仍为假、`scrollTop` 不变。
- 现有主会话场景(S1–S3)断言不变。
### F2 留白只在本轮钉住期间存在(BUG-932)
- `follow()` 的非贴底分支只在 `pinnedHeadRef.current !== null` 时 `applyTurnSpacer`;读者单纯上滑(无 pin)不写留白。`anchorToLatest()` 与 `resetKey` 变化时清 `pinnedHeadRef` 与留白(现在已清)。
- `latestBelowFold` 在无 pin、读者上滑时仍按尾部超出 96px 判定(保留「读者上滑后可跳到最新」)。
- 验收:行为测试新增:长历史落底 → 上滑 400px → 改某条旧消息高度 → 容器 `style` 无 `--conversation-viewport`,`scrollTop` 不变(允许 ±1px)。
### F3 记录
- `docs/BUG_HISTORY.md` 新增 BUG-931、BUG-932(关联 BUG-930);BUG-930 的「验证」补一句真实布局场景已由验收方跑过。
- `docs/tasks/PROGRESS-consultation-answer-start-anchor-fix-20260917.md`;状态板行。
- 真机清单第 5 条拆成两条:点选项后新助手行在顶部;开场轮在顶部。
## 3. 硬红线
沿用原单 §4:滚动逻辑只在 hook 内;单一 hook、单一按钮;不 sticky;改断言写三栏;`page.tsx` 不增行。
## 4. 验收口径
`tsc --noEmit` 0 错(含前置两行修复后);`npm run lint` 0 error;`npm test` 失败清单与 `11c0028d` 逐条一致、新增测试全绿;`next build` 通过、`/` Static、gzip ±2%(此项因门禁红一直没核,本单必须补上)。验收方会用同一套 Chrome 骨架页复跑 S1–S6。
@@ -0,0 +1,97 @@
# 验收修复单 · 会话列表单一数据源:四条源码合同没跟着外壳搬家,门禁卡住(2026-09-17)
## 0. 基线
- 验收对象:`e4e73f56`(会话列表单)与 `6d81062b`(校正输入框守卫单),当前都在 `origin/staging`,head `cc1a8980`。
- 分支:`codex/session-list-single-source-fix-20260917`,从 `origin/staging` 起。
- 范围:`frontend/tests/` 四个合同测试;`tests/test_daily_and_rectification_entrypoints.py`;`frontend/src/app/(app)/page.tsx`(F3,只允许减行或持平);`docs/`。不动 provider、不动排序、不动路由组。
- BUG 段:**BUG-933 起**(基线最大号 BUG-932)。
- **本单是 staging 能否部署的唯一拦路石**:`/api/health` 仍是 `dc2f2a16`,今天四个代码提交一个都没上线。
## 1. 验收结论
### 校正输入框守卫单 `6d81062b`:**通过**
| 任务 | 结果 |
| --- | --- |
| T1 `send()` 会话类型守卫 | 通过。`consultSendBlockedByRectificationSession` 在扣点与撤回窗口之前拦下,保留草稿,转 `openRectificationSession`;刷新恢复重放同样受守卫,pending 存储里指向校正会话的记录直接丢弃 |
| T2 普通输入框只有禁用态 | 通过。`inputDisabled` / `submitBlocked` 都加了 `activeRectificationSession`,占位文案「正在打开生时校正…」,打开失败出「重新打开生时校正」按钮并先清 `rectificationError` |
| T3 三条回退路径 | 通过,且多修了归档一条。`fallbackSessionId` 优先非校正会话,落到校正会话时走 `selectSession` 的延迟切换 |
| T4 服务端错误码 | 通过。类型不对回 `409 session_not_consultation`(在扣点预留之前,不扣点),真查不到仍 404;客户端 `payloadCode` 把 code 带到 `ConsultationResponseError`,命中后回滚乐观消息、不显示报错、直接开校正面 |
| T5 记录 | 通过。BUG-924 / 925、CHANGELOG、DESIGN、VOICE、真机清单齐 |
| `page.tsx` 行数 | 1830 → 1830 |
残留一条 P3,见 F3。
### 会话列表单 `e4e73f56`:**未通过(P1)**
实现本身按任务书落地:多键排序(`ordering` 改数组 + `formatOrderClause`)、`(app)` 路由组 + `(app)/layout.tsx` 常驻外壳 + `SessionListProvider`、`app/` 下 `SidebarProvider` 恰好一次、`use-sidebar-data.ts` 与 `sidebar-data-cache.ts` 已删、服务端排除 `messages = []` 并单独回一条 `draft` 供启动复用、标题改「类别 · M月D日」并删 `uniquify`、`toSidebarSessionRow` 两处侧栏共用。两条让步(元数据操作未上移、延迟落库未做)任务书都允许。
**但 `npm test` 多出 4 条红,且这 4 条会让 staging 门禁必红。**
`.gitea/workflows/backend-quality-gate.yml` 的 `Validate backend, package, frontend, and database contracts` 步骤在 `set -euo pipefail` 下依次跑 `npm test --prefix frontend`、`npm run lint --prefix frontend`。这 4 条是纯源码正则合同,不依赖 Docker、不依赖环境,在门禁机上照样红:
| 测试文件 | 断言找不到的东西 | 它现在在哪 |
| --- | --- | --- |
| `chat-navigation-a11y-contract.test.ts` | `<main className="chat-app">` 紧跟 `replyAnnouncement` 的 sr-only 播报 | `<main>` 在 `(app)/layout.tsx`,播报仍在 `page.tsx`,只是不再相邻 |
| `rectification-history-open-20260909.test.ts` | `page.tsx` 里 `openErrorSessionId={rectificationErrorSessionId}` | `(app)/layout.tsx` 从 `registration.openErrorSessionId` 传给 `AppSidebar` |
| `rectification-surface-contract.test.ts` | `page.tsx` 里 `openingSessionId={rectificationOpeningSessionId}` | 同上,经 `registration.openingSessionId` |
| `chat-session-url.test.ts` | `page.tsx` 里 `const [nextAccount, modelCatalogResult, sessionsPayload]` | 启动改成 `await sessionListReady` + `sessionListBoot()`,这个解构没了 |
四条被保护的性质**都还成立**(`/` 仍 `○ Static`,侧栏行仍拿得到 opening / openError,播报仍在),所以是合同陈旧、不是行为回归。但它们被留在红里,等于把门禁堵死。
根因是流程:执行方只跑了定向测试(进度记录写「定向 26 + 先前 114/115」),没跑全量 `npm test`,也因此没发现同批次带进的两处 TS 错(`session-sidebar-row.ts(30,41)`、`chart-library-session.test.ts(89,42)`,进度记录写着「tsc 0 错」,实测 2 错,后来由滚动单的 `cc1a8980` 顺手修掉)。
## 2. 任务分解
### F1 四条源码合同跟着外壳搬家(BUG-933)
四条都按「原值 / 新值 / 原因」三栏改写,**不得删除测试、不得弱化成只判存在**:
- `chat-navigation-a11y-contract.test.ts`:改为分别断言 `(app)/layout.tsx` 里有且只有一处 `<main className="chat-app">`,且 `page.tsx` 里 `replyAnnouncement` 的 sr-only 播报仍带 `role="status" aria-live="polite" aria-atomic="true"`,并且它是 `SidebarInset` 内容的第一个子节点。
- `rectification-history-open-20260909.test.ts`、`rectification-surface-contract.test.ts`:改为断言 `(app)/layout.tsx` 把 `openErrorSessionId` / `openingSessionId` / `openErrorMessage` 传给 `AppSidebar`,且 `page.tsx` 的 `registerShellControls` 载荷里带这三个字段(两端都断,缺一端就等于断链)。
- `chat-session-url.test.ts`:把「首页自己并发拉账户/目录/列表」的断言改成「首页 `await sessionListReady` 后读 `sessionListBoot()`,并且 `page.tsx` 里不再出现 `fetchSessions(`」,保留它原本要守的「`/` 是静态路由上的客户端读」这一性质。
- 验收:`npm test --prefix frontend` 的失败清单与 `cc1a8980` 比**少这 4 条、不多任何一条**;`npm run lint` 0 error。
### F2 两条陈旧的 Python 入口合同(BUG-934)
`tests/test_daily_and_rectification_entrypoints.py` 断言首页出现 `daily-starlanguage-card` / `birth-rectification-card`,这两个类名在 `62903b7a`(空状态改成「问候 + 输入框 + 两枚入口 pill」)就已删除,全仓已无。**这是既有欠账,不是本批引入**,也不在 `CORE_PYTEST_TARGETS` 里,所以不挡门禁,但任何一次 Python 定向跑都会红。
- 改为断言当前真实的入口形态(`StarterEntries` 的两枚 pill 与 `startDailyStarlanguageConsultation` / 校正入口),或在确认无对应实现后连同该断言一并删除并在测试里写明原因。选哪种由执行方判断后写进进度记录。
- 验收:`.venv/bin/python -m pytest tests/test_daily_and_rectification_entrypoints.py` 全绿。
### F3 校正守卫的残留死角:一个会话都不活跃时输入框静默吞发送(BUG-935)
`page.tsx` 的 `activeSession` 现在是 `sessions.find(id === activeSessionId) ?? sessions.find(非校正)`。当**列表里只剩校正会话**时它是 `undefined`(旧代码 `?? sessions[0]` 不会)。此时:`activeRectificationSession` 为假 → 普通输入框**可用**;按发送 `send()` 因 `!currentSession` 直接 `return false`,没有任何提示;因为 `activeRectificationSession` 为假,「重新打开生时校正」按钮也不出现。
触发路径:账号里只有校正会话,或删除当前普通会话后剩下全是校正会话,且此时校正面正在打开或打开失败。窄,但正是本单要消灭的那类死角。
- 修法:`activeSession` 为空且列表里存在校正会话时,按「校正会话占位」处理——输入框禁用、占位文案「正在打开生时校正…」、打开失败出重开按钮;派生值放进现有 hook 或 `rectification-session-composer-guard.ts`,`page.tsx` 不增行。
- 验收:`rectification-session-composer-guard.test.ts` 加一条纯函数断言(输入「列表全是校正会话、activeSession 为空」→ 锁定态为真);`send()` 在无活跃会话时不得静默返回而不给任何反馈。
### F4 记录
- `docs/BUG_HISTORY.md` 新增 BUG-933 / 934 / 935(933 关联 BUG-927 与 BUG-745;935 关联 BUG-924)。
- BUG-927 的「防复发」补一条:**外壳搬家必须同步搬走针对 `page.tsx` 的源码合同,且交付前必须跑全量 `npm test` 与 `tsc --noEmit`,不得只跑定向。**
- `docs/tasks/PROGRESS-session-list-single-source-fix-20260917.md`;状态板三行改状态。
- T2 的「元数据操作未上移到 provider」目前只写在进度记录里,补进 `BLOCKED.md`,与延迟落库那条并列。
## 3. 硬红线
1. 不得靠删测试或放宽断言过门;四条合同必须仍然守住原来的性质。
2. 不动排序、provider、路由组的既有行为。
3. `page.tsx` 不增行。
4. 交付前必须跑**全量** `npm test --prefix frontend`、`tsc --noEmit`、`next build --webpack`,并把失败清单与 `cc1a8980` 逐条比对后写进进度记录。
## 4. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/session-list-single-source-fix-20260917 .worktrees/session-list-single-source-fix-20260917 origin/staging
cd .worktrees/session-list-single-source-fix-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20
```
## 5. 验收口径
`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 相对 `cc1a8980` 少 4 条红、不新增;`next build --webpack` 通过且 `/` 仍 `○ Static`、首屏 gzip ±2%;`pytest tests/test_daily_and_rectification_entrypoints.py` 全绿。合入后必须核对 `/api/health` 的 `deployment.gitCommit` 等于最近一次含门禁路径的 staging 提交——今天四个代码提交至今一个都没上线。
@@ -0,0 +1,12 @@
# 真机清单 · 回答从开头开始读(2026-09-17)
在 staging 上走。本机无登录态、无 Chrome。
1. 主会话问一个会有长回答的问题(例如「详细分析我的事业格局」):发送后问题行在顶部,回答从它下面开始往下长,视口不跟着最后一个字跑。
2. 回答超过一屏后出现「跳到最新」;按下贴底,并跟随到这一轮结束。
3. 短回答(一两句)问题仍在顶部,下方有留白,不抖动。
4. 切换到别的已有会话:落在底部(最新内容)。
5. 生时校正点选项后:新助手行在顶部,回答从它下面往下长,不贴底。
6. 生时校正开场轮:第一条助手行在顶部。
7. 生时校正打字提交:用户行在顶部;长旁白时出现「跳到最新」,按下能看到选择卡。
8. iPhone 键盘弹起再收起后位置不跳(配合 BUG-920 一起看)。
+11 -1
View File
@@ -223,11 +223,21 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
- **Accessibility:** persistent label where practical; composer has an explicit accessible label. - **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it. - **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
### Answer start anchor
Both chat surfaces pin a new turn at its head instead of following the last streamed token.
- **Head:** the user row of this turn. When this turn has no user row (rectification choice tap, opening, auto-continue) the head is the new assistant row.
- **Pin:** on send (and on those rectification new-turn entries) the scroller places that row at the top with a `space-4` gap and then holds still while the reply grows downward.
- **Spacer:** the last assistant row's `min-height` is `calc(var(--conversation-viewport) - var(--latest-turn-head-height))`, both variables written onto the scroller by `useConversationScrollAnchor`. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
- **History:** opening another session still lands on the newest content once.
- **Jump to latest:** if the growing turn (or the reader scrolling) leaves more than 96px below the fold, the shared chip appears; pressing it sticks to the bottom for the rest of the turn.
### Jump to latest ### Jump to latest
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces. - **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold. - **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors. - **Visibility:** shown when the last turn extends more than 96px below the fold — either because the reader scrolled up, or because this turn grew past the viewport while pinned at its head — and hidden again once they return to the bottom or press it. Sending a question pins the turn head; pressing the control re-anchors to the bottom.
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows. - **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative. - **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
+4 -4
View File
@@ -20,7 +20,7 @@ import {
resolveRectificationEntryAction, resolveRectificationEntryAction,
} from "@/lib/rectification-entry"; } from "@/lib/rectification-entry";
import { import {
dropRectificationStoredPending, composerLocksAsRectification, dropRectificationStoredPending,
ordinaryComposerPlaceholder, ordinaryComposerPlaceholder,
rectificationOrdinaryComposerReopen, rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard"; } from "@/lib/rectification-session-composer-guard";
@@ -391,7 +391,7 @@ export default function Home() {
const activeSession = sessions.find((session) => session.id === activeSessionId) const activeSession = sessions.find((session) => session.id === activeSessionId)
?? sessions.find((session) => session.sessionType !== "birth_time_rectification"); ?? sessions.find((session) => session.sessionType !== "birth_time_rectification");
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"; const activeRectificationSession = composerLocksAsRectification(activeSession, sessions);
const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId); const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId);
const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen); const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen);
const rectificationComposerReopen = rectificationOrdinaryComposerReopen({ activeRectificationSession, error: rectificationError, errorSessionId: rectificationErrorSessionId, sessionId: activeSession?.id }); const rectificationComposerReopen = rectificationOrdinaryComposerReopen({ activeRectificationSession, error: rectificationError, errorSessionId: rectificationErrorSessionId, sessionId: activeSession?.id });
@@ -783,7 +783,7 @@ export default function Home() {
}); });
const jumpToLatestVisible = !rectificationSurfaceOpen const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible && !starterHomeVisible
&& !conversationAnchor.anchored && conversationAnchor.latestBelowFold
&& Boolean(activeSession?.messages.length); && Boolean(activeSession?.messages.length);
function setDraft(value: string) { function setDraft(value: string) {
@@ -1745,7 +1745,7 @@ export default function Home() {
{jumpToLatestVisible && ( {jumpToLatestVisible && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} /> <JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)} )}
{rectificationComposerReopen && activeSession ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession.id)} /> : null} {rectificationComposerReopen ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null}
<ChatComposer <ChatComposer
inputRef={composerInput} inputRef={composerInput}
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"} inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
+5
View File
@@ -1127,6 +1127,11 @@ button:disabled { cursor: default; opacity: .45; }
.message-content { min-width: 0; max-width: min(80%, 680px); } .message-content { min-width: 0; max-width: min(80%, 680px); }
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; } .message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
/* Last turn fills the remaining viewport so a short reply can still pin its
head at the top. Variables are written by useConversationScrollAnchor. */
.conversation .message-list > :last-child .message-assistant {
min-height: calc(var(--conversation-viewport, 0px) - var(--latest-turn-head-height, 0px));
}
.message-thinking { .message-thinking {
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
} }
@@ -464,8 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const boardId = useId(); const boardId = useId();
const boardTitleId = useId(); const boardTitleId = useId();
const composerRemainingId = useId(); const composerRemainingId = useId();
// The same anchor-and-follow the consultation surface uses: streamed tokens and // Same pin-the-turn-head hook as the consultation surface (BUG-930).
// new cards land the viewport on the bottom only while the reader is there.
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId); const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -717,6 +716,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: []), : []),
liveRow, liveRow,
])); ]));
if (!continuation) conversationAnchor.pinLatestTurn();
let raw = ""; let raw = "";
let runOutcome: "succeeded" | "stopped" | "failed" = "failed"; let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
@@ -1079,6 +1079,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, },
}, },
]); ]);
conversationAnchor.pinLatestTurn();
setPending(true); setPending(true);
const abortController = new AbortController(); const abortController = new AbortController();
runAbort.current = abortController; runAbort.current = abortController;
@@ -1231,6 +1232,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
}, },
]); ]);
conversationAnchor.pinLatestTurn();
const abortController = new AbortController(); const abortController = new AbortController();
runAbort.current = abortController; runAbort.current = abortController;
try { try {
@@ -1812,7 +1814,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</section> </section>
<div className="composer-wrap"> <div className="composer-wrap">
{!conversationAnchor.anchored && ( {conversationAnchor.latestBelowFold && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} /> <JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)} )}
<ChatComposer <ChatComposer
+17 -2
View File
@@ -64,6 +64,7 @@ import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace }
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types"; import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
import { import {
consultSendBlockedByRectificationSession, consultSendBlockedByRectificationSession,
missingActiveSessionSendAction,
SESSION_NOT_CONSULTATION_CODE, SESSION_NOT_CONSULTATION_CODE,
} from "@/lib/rectification-session-composer-guard"; } from "@/lib/rectification-session-composer-guard";
import type { import type {
@@ -86,7 +87,9 @@ import type { PublicLanguageModelCatalog } from "@/lib/public-models";
type ConversationAnchor = { type ConversationAnchor = {
readonly anchored: boolean; readonly anchored: boolean;
readonly latestBelowFold: boolean;
readonly anchorToLatest: () => void; readonly anchorToLatest: () => void;
readonly pinLatestTurn: (target?: HTMLElement) => void;
}; };
export type ConsultationRunParams = { export type ConsultationRunParams = {
@@ -512,7 +515,19 @@ export function useConsultationRun(params: ConsultationRunParams) {
? sessions.find((session) => session.id === targetSessionId) ? sessions.find((session) => session.id === targetSessionId)
: activeSession; : activeSession;
const currentSession = options.sessionOverride ?? liveSession; const currentSession = options.sessionOverride ?? liveSession;
if (!question || !currentSession || !modelCatalog || !account) return false; if (!question) return false;
if (!currentSession) {
const action = missingActiveSessionSendAction(sessions);
if ("openSessionId" in action) {
setDraft(originalQuestion);
await openRectificationSession(action.openSessionId);
setDraft(originalQuestion);
return false;
}
setComposerNotice(action.notice);
return false;
}
if (!modelCatalog || !account) return false;
if (consultSendBlockedByRectificationSession(currentSession)) { if (consultSendBlockedByRectificationSession(currentSession)) {
if (resuming) { if (resuming) {
pendingConsultation.current = null; pendingConsultation.current = null;
@@ -657,7 +672,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
} }
setOnboardingJustCompleted(false); setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession); updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest(); conversationAnchor.pinLatestTurn();
setDraft(""); setDraft("");
setDraftTheme(null); setDraftTheme(null);
setDraftEntrypoint(null); setDraftEntrypoint(null);
@@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
export const conversationAnchorThreshold = 96; export const conversationAnchorThreshold = 96;
export const conversationPinSpaceToken = "--space-4";
const pinSpaceFallbackPx = 16;
type AnchorState = { type AnchorState = {
readonly key: string; readonly key: string;
@@ -12,7 +14,9 @@ type AnchorState = {
type ConversationScrollAnchor = { type ConversationScrollAnchor = {
readonly anchored: boolean; readonly anchored: boolean;
readonly latestBelowFold: boolean;
readonly anchorToLatest: () => void; readonly anchorToLatest: () => void;
readonly pinLatestTurn: (target?: HTMLElement) => void;
}; };
export function conversationDistanceFromBottom(container: HTMLElement) { export function conversationDistanceFromBottom(container: HTMLElement) {
@@ -24,28 +28,125 @@ export function nextAnchorState(anchored: boolean, distanceFromBottom: number, s
return scrolledUp ? false : anchored; return scrolledUp ? false : anchored;
} }
export function readPinSpacePx(element: HTMLElement): number {
if (typeof getComputedStyle !== "function") return pinSpaceFallbackPx;
const raw = getComputedStyle(element).getPropertyValue(conversationPinSpaceToken).trim();
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? parsed : pinSpaceFallbackPx;
}
const DOCUMENT_POSITION_FOLLOWING = 4;
export function isNodeAfter(earlier: Node, later: Node): boolean {
if (typeof earlier.compareDocumentPosition !== "function") return false;
return (earlier.compareDocumentPosition(later) & DOCUMENT_POSITION_FOLLOWING) !== 0;
}
/**
* Turn head is the latest user row, unless an assistant sits after it with
* another assistant in between (choice / opening / auto-continue: this turn
* has no user row) or there is no user row at all.
*/
export function resolveTurnHead(container: ParentNode, target?: HTMLElement | null): HTMLElement | null {
if (target) return target;
const users = Array.from(container.querySelectorAll(".message-user")) as HTMLElement[];
const assistants = Array.from(container.querySelectorAll(".message-assistant")) as HTMLElement[];
const lastUser = users.at(-1) ?? null;
const lastAssistant = assistants.at(-1) ?? null;
if (!lastUser) return lastAssistant;
if (!lastAssistant) return lastUser;
if (!isNodeAfter(lastUser, lastAssistant)) return lastUser;
const between = assistants.some((row) => isNodeAfter(lastUser, row) && isNodeAfter(row, lastAssistant));
return between ? lastAssistant : lastUser;
}
export function offsetWithinScroller(scroller: HTMLElement, node: HTMLElement): number {
return node.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
}
export function pinTurnScrollTop(scroller: HTMLElement, row: HTMLElement, spacePx: number): number {
return Math.max(0, offsetWithinScroller(scroller, row) - spacePx);
}
export function applyFollowBottom(
element: { scrollTop: number; scrollHeight: number },
anchored: boolean,
): void {
if (!anchored) return;
element.scrollTop = element.scrollHeight;
}
export function lastTurnTail(container: HTMLElement): HTMLElement | null {
return (container.querySelector(".message-list > :last-child .message-assistant")
?? container.querySelector(".message-assistant:last-of-type")
?? container.querySelector(".message-user:last-of-type")) as HTMLElement | null;
}
export function turnHeadHeightForSpacer(head: HTMLElement | null, tail: HTMLElement | null): number {
if (!head) return 0;
// The last assistant carries the min-height spacer. Measuring it as the turn
// head feeds the spacer back into itself (BUG-931).
if (tail && head === tail) return 0;
return head.offsetHeight;
}
export function applyTurnSpacer(container: HTMLElement, head: HTMLElement | null): void {
container.style.setProperty("--conversation-viewport", `${container.clientHeight}px`);
container.style.setProperty(
"--latest-turn-head-height",
`${turnHeadHeightForSpacer(head, lastTurnTail(container))}px`,
);
}
export function clearTurnSpacer(container: HTMLElement): void {
container.style.removeProperty("--conversation-viewport");
container.style.removeProperty("--latest-turn-head-height");
}
export function turnTailOverflow(container: HTMLElement): number {
const last = lastTurnTail(container);
if (!last) return conversationDistanceFromBottom(container);
return last.offsetTop + last.offsetHeight - (container.scrollTop + container.clientHeight);
}
export function latestContentBelowFold(overflow: number): boolean {
return overflow > conversationAnchorThreshold;
}
/** /**
* Owns both halves of "follow the conversation": whether the reader is * Owns both halves of "follow the conversation": whether the reader is
* anchored to the bottom (a scroll listener), and landing the viewport on the * anchored to the bottom (a scroll listener), and landing the viewport on the
* newest content while they are (a resize observer over the scroller's * newest content while they are (a resize observer over the scroller's
* children, one frame per change). Both chat surfaces use this one hook, so * children, one frame per change). Both chat surfaces use this one hook.
* streamed tokens never call scrollTo directly and settlement never adds a *
* second, smooth scroll on top of the follow. * A new turn pins its head (the user row, or the new assistant when this turn
* has no user row) at the top and does not follow streamed growth. Switching
* conversations still lands on the newest content once.
*/ */
export function useConversationScrollAnchor( export function useConversationScrollAnchor(
container: RefObject<HTMLElement | null>, container: RefObject<HTMLElement | null>,
active: boolean, active: boolean,
resetKey: string, resetKey: string,
): ConversationScrollAnchor { ): ConversationScrollAnchor {
const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: true }); const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: false });
const [latestBelowFold, setLatestBelowFold] = useState(false);
const lastScrollTop = useRef(0); const lastScrollTop = useRef(0);
const anchoredRef = useRef(true); const anchoredRef = useRef(false);
const shouldLand = useRef(true);
const holdUnpin = useRef(false);
const pinnedHeadRef = useRef<HTMLElement | null>(null);
const anchored = anchor.key === resetKey ? anchor.anchored : true; const anchored = anchor.key === resetKey ? anchor.anchored : true;
useEffect(() => { useEffect(() => {
anchoredRef.current = anchored; anchoredRef.current = anchored;
}, [anchored]); }, [anchored]);
useEffect(() => {
shouldLand.current = true;
holdUnpin.current = false;
pinnedHeadRef.current = null;
}, [resetKey]);
useEffect(() => { useEffect(() => {
const element = container.current; const element = container.current;
if (!active || !element) return; if (!active || !element) return;
@@ -53,9 +154,20 @@ export function useConversationScrollAnchor(
let frame = 0; let frame = 0;
const measure = () => { const measure = () => {
frame = 0; frame = 0;
const overflow = turnTailOverflow(element);
const distance = conversationDistanceFromBottom(element); const distance = conversationDistanceFromBottom(element);
const scrolledUp = element.scrollTop < lastScrollTop.current; const scrolledUp = element.scrollTop < lastScrollTop.current;
lastScrollTop.current = element.scrollTop; lastScrollTop.current = element.scrollTop;
setLatestBelowFold(latestContentBelowFold(overflow));
if (holdUnpin.current) {
if (scrolledUp) {
holdUnpin.current = false;
} else if (distance <= conversationAnchorThreshold) {
holdUnpin.current = false;
} else {
return;
}
}
setAnchor((current) => { setAnchor((current) => {
const currentAnchored = current.key === resetKey ? current.anchored : true; const currentAnchored = current.key === resetKey ? current.anchored : true;
const next = nextAnchorState(currentAnchored, distance, scrolledUp); const next = nextAnchorState(currentAnchored, distance, scrolledUp);
@@ -76,15 +188,31 @@ export function useConversationScrollAnchor(
// Follow: while anchored, any change in the scroller's content height lands the // Follow: while anchored, any change in the scroller's content height lands the
// viewport on the bottom, at most once per frame. Switching conversations // viewport on the bottom, at most once per frame. Switching conversations
// (resetKey) lands there immediately. // (resetKey) lands there immediately. Unanchored turns only refresh the spacer.
useEffect(() => { useEffect(() => {
const element = container.current; const element = container.current;
if (!active || !element) return; if (!active || !element) return;
let frame = 0; let frame = 0;
const follow = () => { const follow = () => {
frame = 0; frame = 0;
if (!anchoredRef.current) return; if (shouldLand.current) {
element.scrollTop = element.scrollHeight; element.scrollTop = element.scrollHeight;
shouldLand.current = false;
holdUnpin.current = false;
pinnedHeadRef.current = null;
clearTurnSpacer(element);
const atBottom = conversationDistanceFromBottom(element) <= conversationAnchorThreshold;
anchoredRef.current = atBottom;
setAnchor({ key: resetKey, anchored: atBottom });
setLatestBelowFold(false);
return;
}
if (anchoredRef.current) {
element.scrollTop = element.scrollHeight;
} else if (pinnedHeadRef.current) {
applyTurnSpacer(element, pinnedHeadRef.current);
}
setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
}; };
const requestFollow = () => { const requestFollow = () => {
if (frame) return; if (frame) return;
@@ -118,13 +246,47 @@ export function useConversationScrollAnchor(
return { return {
anchored, anchored,
latestBelowFold,
anchorToLatest: () => { anchorToLatest: () => {
const element = container.current; const element = container.current;
holdUnpin.current = false;
shouldLand.current = false;
pinnedHeadRef.current = null;
if (element) { if (element) {
clearTurnSpacer(element);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" }); element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" });
setLatestBelowFold(false);
} }
setAnchor({ key: resetKey, anchored: true }); setAnchor({ key: resetKey, anchored: true });
}, },
pinLatestTurn: (target?: HTMLElement) => {
const element = container.current;
const run = () => {
shouldLand.current = false;
holdUnpin.current = true;
anchoredRef.current = false;
if (!element) {
setAnchor({ key: resetKey, anchored: false });
setLatestBelowFold(false);
return;
}
const head = resolveTurnHead(element, target);
pinnedHeadRef.current = head;
applyTurnSpacer(element, head);
const space = readPinSpacePx(element);
const top = head ? pinTurnScrollTop(element, head, space) : 0;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top, behavior: reduceMotion ? "auto" : "smooth" });
lastScrollTop.current = element.scrollTop;
setAnchor({ key: resetKey, anchored: false });
setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
};
if (typeof window === "undefined") {
run();
return;
}
window.requestAnimationFrame(run);
},
}; };
} }
@@ -23,6 +23,24 @@ export function consultSendBlockedByRectificationSession(
return isRectificationChatSession(session); return isRectificationChatSession(session);
} }
/** Empty active session with only rectification rows still occupies the ordinary composer. */
export function composerLocksAsRectification(
activeSession: { sessionType?: string } | null | undefined,
sessions: readonly { sessionType: string }[],
): boolean {
if (isRectificationChatSession(activeSession)) return true;
if (activeSession) return false;
return sessions.some((session) => session.sessionType === "birth_time_rectification");
}
export function missingActiveSessionSendAction(
sessions: readonly { id: string; sessionType: string }[],
): { openSessionId: string } | { notice: string } {
const rectification = sessions.find((session) => session.sessionType === "birth_time_rectification");
if (rectification) return { openSessionId: rectification.id };
return { notice: "请先选一条对话,或点上方「新建对话」。" };
}
export function dropRectificationStoredPending< export function dropRectificationStoredPending<
S extends { sessionId: string }, S extends { sessionId: string },
T extends { id: string; sessionType: string }, T extends { id: string; sessionType: string },
@@ -63,5 +81,6 @@ export function rectificationOrdinaryComposerReopen(input: {
if (!input.activeRectificationSession || !input.error || input.error === "profile_incomplete") { if (!input.activeRectificationSession || !input.error || input.error === "profile_incomplete") {
return false; return false;
} }
if (!input.sessionId) return true;
return !input.errorSessionId || input.errorSessionId === input.sessionId; return !input.errorSessionId || input.errorSessionId === input.sessionId;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export function toSidebarSessionRow(
): SidebarSession { ): SidebarSession {
return { return {
id: session.id, id: session.id,
title: sessionSidebarTitle(session, library), title: sessionSidebarTitle(session),
subtitle: sessionSidebarSubtitle(session, library) || null, subtitle: sessionSidebarSubtitle(session, library) || null,
pinned: session.pinned, pinned: session.pinned,
archived: Boolean(session.archivedAt), archived: Boolean(session.archivedAt),
+4 -1
View File
@@ -86,7 +86,10 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
archivedAt: null, archivedAt: null,
messagesHydrated: true, messagesHydrated: true,
}; };
assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机"); // 原值: sessionSidebarTitle(base, library)
// 新值: sessionSidebarTitle(base)
// 原因: sessionSidebarTitle 只吃 session;第二参是 e4e73f56 的 TS 错,挡住 tsc / next build
assert.equal(sessionSidebarTitle(base), "半年内换工作时机");
assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14"); assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14");
const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const }; const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const };
assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方"); assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方");
@@ -61,7 +61,7 @@ test("both insufficient-credit paths are soft so the typed question is not throw
assert.match(creditGuard, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/); assert.match(creditGuard, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
assert.doesNotMatch(creditGuard, /window\.location/); assert.doesNotMatch(creditGuard, /window\.location/);
const creditPush = sendSource.indexOf('openAccountDialog("billing", { source: "insufficient-credits" })'); const creditPush = sendSource.indexOf('openAccountDialog("billing", { source: "insufficient-credits" })');
const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");"); const sendClearsComposer = sendSource.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");");
assert.ok(creditPush >= 0 && sendClearsComposer > creditPush); assert.ok(creditPush >= 0 && sendClearsComposer > creditPush);
assert.equal(pageSource.match(/openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 2); assert.equal(pageSource.match(/openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 2);
}); });
@@ -108,10 +108,16 @@ test("bootstrap failure retries with a hard reload because refresh cannot rerun
}); });
test("the streaming reply announces state transitions, never streamed deltas", () => { test("the streaming reply announces state transitions, never streamed deltas", () => {
// Given: one page-level status region outside every aria-busy subtree. // 原值:page.tsx 里 `<main className="chat-app">` 紧跟 replyAnnouncement 的 sr-only 播报
// 新值:`<main className="chat-app">` 只在 (app)/layout.tsx 出现一次;播报仍在 page.tsx,是 SidebarInset 内容的第一个子节点
// 原因:T2 外壳搬家,播报仍在对话页、仍在 aria-busy 列表之外
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.equal(layout.match(/<main className="chat-app">/g)?.length, 1);
assert.doesNotMatch(pageFile, /<main className="chat-app">/);
assert.match( assert.match(
pageSource, pageFile,
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/, /return \(\s*<>\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
); );
// Then: the region is fed a phase-derived string, not the streaming text. // Then: the region is fed a phase-derived string, not the streaming text.
@@ -3,7 +3,17 @@ import { readFileSync } from "node:fs";
import test from "node:test"; import test from "node:test";
import { noticeTone } from "../src/lib/chat-notice.ts"; import { noticeTone } from "../src/lib/chat-notice.ts";
import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts"; import {
applyFollowBottom,
applyTurnSpacer,
clearTurnSpacer,
conversationAnchorThreshold,
latestContentBelowFold,
nextAnchorState,
pinTurnScrollTop,
resolveTurnHead,
turnTailOverflow,
} from "../src/hooks/use-conversation-scroll-anchor.ts";
import { homeSurface } from "./home-surface.ts"; import { homeSurface } from "./home-surface.ts";
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
@@ -72,12 +82,12 @@ test("anchors the streaming scroll instead of following every token", () => {
// `container.scrollTo` after an `anchored` guard. That effect ran once per token and added // `container.scrollTo` after an `anchored` guard. That effect ran once per token and added
// a second smooth scroll on settle; the follow now lives in the hook, driven by a // a second smooth scroll on settle; the follow now lives in the hook, driven by a
// ResizeObserver over the scroller's children, one frame per change (BUG-478). // ResizeObserver over the scroller's children, one frame per change (BUG-478).
// 原值: follow() 在 !anchored 时直接 return
// 新值: follow() 仅在 anchored 时写 scrollTop = scrollHeight,未钉住时只刷新 spacer
// 原因: BUG-930 流式期间视口钉在本轮开头,留白变量仍要随高度更新
assert.doesNotMatch(pageSource, /container\.scrollTo\(/); assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
const follow = sourceBetween(anchorSource, "const follow = () => {", "};"); assert.match(anchorSource, /if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;/);
assert.match(follow, /if \(!anchoredRef\.current\) return/); assert.match(anchorSource, /applyTurnSpacer\(element,/);
const scrollGuard = follow.indexOf("if (!anchoredRef.current) return");
const scrollCall = follow.indexOf("element.scrollTop = element.scrollHeight");
assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/); assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/);
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/); assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/); assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
@@ -88,9 +98,12 @@ test("scrolls to the newest turn on intentional jumps", () => {
assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/); assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/);
assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/); assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/);
// And: sending a question re-anchors before the optimistic turn renders. // 原值: send() 调 anchorToLatest(),乐观入列后贴底
// 新值: send() 调 pinLatestTurn(),下一帧把本轮开头钉到顶部
// 原因: BUG-930
const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");"); const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/); assert.match(sendBlock, /conversationAnchor\.pinLatestTurn\(\)/);
assert.doesNotMatch(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
}); });
test("offers an accessible jump-to-latest control while reading history", () => { test("offers an accessible jump-to-latest control while reading history", () => {
@@ -98,11 +111,14 @@ test("offers an accessible jump-to-latest control while reading history", () =>
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of // `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478). // two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS. // Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
// 原值: jumpToLatestVisible 看 !conversationAnchor.anchored
// 新值: 看 conversationAnchor.latestBelowFold(底部距离 > 96px)
// 原因: BUG-930 回答长出视口时即使读者没主动上滑也要出现「跳到最新」
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}"); const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8"); const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? ""; const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/); assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& conversationAnchor\.latestBelowFold/);
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/); assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(jumpSource, /type="button"/); assert.match(jumpSource, /type="button"/);
assert.match(jumpSource, /aria-label="跳到最新"/); assert.match(jumpSource, /aria-label="跳到最新"/);
@@ -143,3 +159,221 @@ test("re-anchors once the reader returns to the newest turn", () => {
assert.equal(nextAnchorState(false, 600, false), false); assert.equal(nextAnchorState(false, 600, false), false);
assert.equal(nextAnchorState(false, 40, false), true); assert.equal(nextAnchorState(false, 40, false), true);
}); });
test("pins a new turn at the head instead of following streamed growth", () => {
const user = fakeRow("message-user", 16, 48);
const assistant = fakeRow("message-assistant", 80, 200);
const scroller = fakeScroller([user, assistant], 400);
const pinned = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
scroller.scrollTop = pinned;
const start = scroller.scrollTop;
for (let i = 0; i < 5; i += 1) {
assistant.offsetHeight += 80;
scroller.scrollHeight += 80;
applyFollowBottom(scroller, false);
}
assert.equal(scroller.scrollTop, start);
assert.equal(start, user.offsetTop - 16);
applyFollowBottom(scroller, true);
assert.equal(scroller.scrollTop, scroller.scrollHeight);
});
test("latestBelowFold tracks overflow past the fold and clears at the bottom", () => {
const user = fakeRow("message-user", 16, 48);
const assistant = fakeRow("message-assistant", 80, 200);
const scroller = fakeScroller([user, assistant], 400);
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
assistant.offsetHeight = 1200;
scroller.scrollHeight = assistant.offsetTop + assistant.offsetHeight + 24;
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), true);
assert.ok(turnTailOverflow(asElement(scroller)) > conversationAnchorThreshold);
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
});
test("resolveTurnHead prefers the user row unless this turn has no user", () => {
const typedUser = fakeRow("message-user", 8, 40);
const typedAssistant = fakeRow("message-assistant", 64, 80);
const typed = fakeScroller([typedUser, typedAssistant], 400);
assert.equal(resolveTurnHead(asElement(typed)), asElement(typedUser));
const priorUser = fakeRow("message-user", 8, 40);
const priorAssistant = fakeRow("message-assistant", 64, 80);
const choiceAssistant = fakeRow("message-assistant", 160, 80);
const choice = fakeScroller([priorUser, priorAssistant, choiceAssistant], 400);
assert.equal(resolveTurnHead(asElement(choice)), asElement(choiceAssistant));
const opening = fakeRow("message-assistant", 0, 80);
const openingScroller = fakeScroller([opening], 400);
assert.equal(resolveTurnHead(asElement(openingScroller)), asElement(opening));
});
test("turn spacer uses conversation viewport variables and is not sticky", () => {
assert.match(
globalsSource,
/\.conversation \.message-list > :last-child \.message-assistant \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
);
const spacerRule = sourceBetween(
globalsSource,
".conversation .message-list > :last-child .message-assistant {",
"}",
);
assert.doesNotMatch(spacerRule, /sticky/);
assert.doesNotMatch(anchorSource, /position:\s*sticky/);
const scroller = fakeScroller([], 400);
const head = fakeRow("message-user", 16, 48);
applyTurnSpacer(asElement(scroller), asElement(head));
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "400px");
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "48px");
clearTurnSpacer(asElement(scroller));
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "");
});
test("a turn with no user row pins the new assistant and uses a full-viewport spacer", () => {
const priorUser = fakeRow("message-user", 8, 40);
const priorAssistant = fakeRow("message-assistant", 56, 80);
const laterUser = fakeRow("message-user", 152, 40);
const laterAssistant = fakeRow("message-assistant", 200, 80);
const incoming = fakeRow("message-assistant", 480, 80);
const scroller = fakeScroller([priorUser, priorAssistant, laterUser, laterAssistant, incoming], 600);
const head = resolveTurnHead(asElement(scroller));
assert.equal(head, asElement(incoming));
applyTurnSpacer(asElement(scroller), head);
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "0px");
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(incoming), 16);
assert.equal(scroller.scrollTop, incoming.offsetTop - 16);
const start = scroller.scrollTop;
for (let i = 0; i < 3; i += 1) {
incoming.offsetHeight += 80;
scroller.scrollHeight += 80;
applyFollowBottom(scroller, false);
}
assert.equal(scroller.scrollTop, start);
});
test("idle history does not write a spacer when the reader has only scrolled up", () => {
// 原值: follow() 在 !anchored 时无条件 applyTurnSpacer
// 新值: 只在 pinnedHeadRef 非空时写留白
// 原因: BUG-932 闲置会话上滑后子元素尺寸变化不应把最后一条助手行撑开
assert.match(
anchorSource,
/if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;\s*\} else if \(pinnedHeadRef\.current\) \{\s*applyTurnSpacer\(element, pinnedHeadRef\.current\);/,
);
assert.match(anchorSource, /pinnedHeadRef\.current = null;\s*if \(element\) \{\s*clearTurnSpacer\(element\);/);
const user = fakeRow("message-user", 8, 40);
const assistant = fakeRow("message-assistant", 56, 900);
const scroller = fakeScroller([user, assistant], 600);
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
scroller.scrollTop = Math.max(0, scroller.scrollTop - 400);
const start = scroller.scrollTop;
assistant.offsetHeight += 30;
scroller.scrollHeight += 30;
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
assert.equal(Math.abs(scroller.scrollTop - start) <= 1, true);
});
test("consultation send and rectification new turns call pinLatestTurn", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const pins = chat.match(/conversationAnchor\.pinLatestTurn\(\)/g) ?? [];
assert.ok(pins.length >= 3, `expected three pinLatestTurn calls, got ${pins.length}`);
assert.doesNotMatch(chat, /conversation\.current\?\.scrollTo|element\.scrollTo/);
assert.match(chat, /conversationAnchor\.latestBelowFold/);
assert.match(anchorSource, /window\.requestAnimationFrame\(run\)/);
});
type FakeRow = {
className: string;
offsetTop: number;
offsetHeight: number;
order: number;
compareDocumentPosition: (other: Node) => number;
getBoundingClientRect: () => DOMRect;
};
type FakeScroller = {
rows: FakeRow[];
scrollTop: number;
clientHeight: number;
scrollHeight: number;
children: unknown[];
style: {
setProperty: (name: string, value: string) => void;
getPropertyValue: (name: string) => string;
removeProperty: (name: string) => void;
};
querySelectorAll: (selector: string) => FakeRow[];
querySelector: (selector: string) => FakeRow | null;
getBoundingClientRect: () => DOMRect;
};
function asElement(value: FakeRow | FakeScroller): HTMLElement {
return value as unknown as HTMLElement;
}
function fakeRow(className: string, offsetTop: number, offsetHeight: number): FakeRow {
const row = {
className,
offsetTop,
offsetHeight,
order: 0,
compareDocumentPosition(other: Node) {
const theirOrder = (other as unknown as FakeRow).order ?? 0;
if (this.order < theirOrder) return 4;
if (this.order > theirOrder) return 2;
return 0;
},
getBoundingClientRect() {
return { top: this.offsetTop, left: 0, right: 0, bottom: this.offsetTop + this.offsetHeight, width: 0, height: this.offsetHeight, x: 0, y: this.offsetTop, toJSON() { return this; } };
},
};
return row;
}
function fakeScroller(rows: FakeRow[], clientHeight: number): FakeScroller {
rows.forEach((row, index) => {
row.order = index;
});
const last = rows.at(-1);
const styleValues = new Map<string, string>();
const scroller = {
rows,
scrollTop: 0,
clientHeight,
scrollHeight: last ? last.offsetTop + last.offsetHeight + 24 : clientHeight,
children: [],
style: {
setProperty(name: string, value: string) {
styleValues.set(name, value);
},
getPropertyValue(name: string) {
return styleValues.get(name) ?? "";
},
removeProperty(name: string) {
styleValues.delete(name);
},
},
querySelectorAll(selector: string) {
const token = selector.replace(".", "");
return rows.filter((row) => row.className === token);
},
querySelector(selector: string) {
if (selector.includes(":last-child .message-assistant") || selector.includes(".message-assistant:last-of-type")) {
return rows.filter((row) => row.className === "message-assistant").at(-1) ?? null;
}
if (selector.includes(".message-user:last-of-type")) {
return rows.filter((row) => row.className === "message-user").at(-1) ?? null;
}
return null;
},
getBoundingClientRect() {
return { top: 0, left: 0, right: 0, bottom: clientHeight, width: 0, height: clientHeight, x: 0, y: 0, toJSON() { return this; } };
},
};
return scroller;
}
+9 -2
View File
@@ -213,12 +213,19 @@ test("home stays a client-read query on a static route", () => {
assert.match(page, /window\.history\.replaceState/); assert.match(page, /window\.history\.replaceState/);
assert.match(lib, /history\.pushState\(null, "", next\)/); assert.match(lib, /history\.pushState\(null, "", next\)/);
assert.match(lib, /history\.replaceState\(null, "", next\)/); assert.match(lib, /history\.replaceState\(null, "", next\)/);
// 原值:首页自己并发拉账户/目录/列表,`const [nextAccount, modelCatalogResult, sessionsPayload]`
// 新值:`await sessionListReady` 后读 `sessionListBoot()`,page.tsx 不再 `fetchSessions(`
// 原因:T2 列表由 layout provider 拉一次;本条仍守「`/` 是静态路由上的客户端读」
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const preview = sourceBetween( const preview = sourceBetween(
page, pageFile,
"if (previewMode) {", "if (previewMode) {",
"const [nextAccount, modelCatalogResult, sessionsPayload]", "await sessionListReady;",
); );
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/); assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
assert.match(pageFile, /await sessionListReady;/);
assert.match(pageFile, /sessionListBoot\(\)/);
assert.doesNotMatch(pageFile, /fetchSessions\(/);
assert.match(page, /SESSION_MISSING_NOTICE/); assert.match(page, /SESSION_MISSING_NOTICE/);
assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除"); assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除");
}); });
@@ -341,7 +341,10 @@ test("Agentic rectification follows the conversation tail only while the reader
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/); assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
assert.match(chat, /<section[\s\S]*ref=\{conversation\}[\s\S]*className="conversation is-rectification"/); assert.match(chat, /<section[\s\S]*ref=\{conversation\}[\s\S]*className="conversation is-rectification"/);
assert.match(chat, /const conversationAnchor = useConversationScrollAnchor\(conversation, true, caseId\)/); assert.match(chat, /const conversationAnchor = useConversationScrollAnchor\(conversation, true, caseId\)/);
assert.match(chat, /\{!conversationAnchor\.anchored && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/); // 原值: !conversationAnchor.anchored
// 新值: conversationAnchor.latestBelowFold
// 原因: BUG-930 两个面同一套「本轮开头钉顶」,跳到最新看内容是否长出视口
assert.match(chat, /\{conversationAnchor\.latestBelowFold && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.doesNotMatch(chat, /followTailRef|isNearBottom|shouldShowJumpToLatest|choiceCardsOpen|followLatestContent|updateFollowState|回到最新|onScroll=/); assert.doesNotMatch(chat, /followTailRef|isNearBottom|shouldShowJumpToLatest|choiceCardsOpen|followLatestContent|updateFollowState|回到最新|onScroll=/);
assert.doesNotMatch(styles, /\.rectification-jump-latest/); assert.doesNotMatch(styles, /\.rectification-jump-latest/);
assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/); assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/);
@@ -1457,7 +1457,10 @@ test("the public agent route treats structured choice as a non-model command", (
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow // Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
// flag (BUG-478). The shared control and anchor are locked instead. // flag (BUG-478). The shared control and anchor are locked instead.
assert.match(chat, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/); assert.match(chat, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(chat, /conversationAnchor\.anchored/); // 原值: conversationAnchor.anchored
// 新值: conversationAnchor.latestBelowFold
// 原因: BUG-930
assert.match(chat, /conversationAnchor\.latestBelowFold/);
}); });
test("rectification attempt timeout stays under the agent route budget", () => { test("rectification attempt timeout stays under the agent route budget", () => {
@@ -55,11 +55,15 @@ test("a history-list open failure shows under the clicked row, not on the starte
pageSource, pageSource,
/rectificationError=\{rectificationErrorSessionId \? "" : rectificationError\}/, /rectificationError=\{rectificationErrorSessionId \? "" : rectificationError\}/,
); );
assert.match(pageSource, /openErrorSessionId=\{rectificationErrorSessionId\}/); // 原值:page.tsx 把 openErrorSessionId / openErrorMessage 直接传给 AppSidebar
assert.match( // 新值:layout 从 registration 传给 AppSidebar,首页 registerShellControls 载荷带这两个字段
pageSource, // 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到打开失败
/openErrorMessage=\{rectificationErrorSessionId \? rectificationErrorMessage : ""\}/, const layout = read("../src/app/(app)/layout.tsx");
); const shell = read("../src/hooks/use-home-shell-registration.ts");
assert.match(layout, /openErrorSessionId=\{registration\?\.openErrorSessionId \?\? null\}/);
assert.match(layout, /openErrorMessage=\{registration\?\.openErrorMessage \?\? ""\}/);
assert.match(shell, /openErrorSessionId: rectificationErrorSessionId,/);
assert.match(shell, /openErrorMessage: rectificationErrorSessionId \? rectificationErrorMessage : "",/);
assert.match(starter, /rectificationError && !rectificationSurfaceOpen && \(/); assert.match(starter, /rectificationError && !rectificationSurfaceOpen && \(/);
assert.match(sidebar, /openErrorSessionId/); assert.match(sidebar, /openErrorSessionId/);
assert.match(sidebarRow, /error \? \(/); assert.match(sidebarRow, /error \? \(/);
@@ -70,5 +74,5 @@ test("a history-list open failure shows under the clicked row, not on the starte
const notice = cssDeclarations(".session-open-error", styles); const notice = cssDeclarations(".session-open-error", styles);
assert.match(notice, /color:\s*var\(--color-danger\)/); assert.match(notice, /color:\s*var\(--color-danger\)/);
assert.match(notice, /font-size:\s*var\(--type-overline\)/); assert.match(notice, /font-size:\s*var\(--type-overline\)/);
assert.match(page, /openErrorSessionId=\{rectificationErrorSessionId\}/); assert.match(layout, /openErrorSessionId=\{registration\?\.openErrorSessionId \?\? null\}/);
}); });
@@ -4,9 +4,11 @@ import test from "node:test";
import { homeSurface as page } from "./home-surface.ts"; import { homeSurface as page } from "./home-surface.ts";
import { import {
composerLocksAsRectification,
consultSendBlockedByRectificationSession, consultSendBlockedByRectificationSession,
dropRectificationStoredPending, dropRectificationStoredPending,
fallbackSessionId, fallbackSessionId,
missingActiveSessionSendAction,
RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER, RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER,
RECTIFICATION_REOPEN_LABEL, RECTIFICATION_REOPEN_LABEL,
rectificationOrdinaryComposerReopen, rectificationOrdinaryComposerReopen,
@@ -104,3 +106,34 @@ test("delete and popstate fallbacks do not setActiveSessionId onto a rectificati
assert.match(sessions, /const fallbackId = fallbackSessionId\(listed\);/); assert.match(sessions, /const fallbackId = fallbackSessionId\(listed\);/);
assert.match(page, /sessions.find\(\(session\) => session.sessionType !== "birth_time_rectification"\)/); assert.match(page, /sessions.find\(\(session\) => session.sessionType !== "birth_time_rectification"\)/);
}); });
test("an empty active session still locks the composer when the list is only rectification", () => {
assert.equal(
composerLocksAsRectification(undefined, [{ sessionType: "birth_time_rectification" }]),
true,
);
assert.equal(
composerLocksAsRectification(undefined, [{ sessionType: "consultation" }]),
false,
);
assert.equal(
composerLocksAsRectification({ sessionType: "consultation" }, [{ sessionType: "birth_time_rectification" }]),
false,
);
assert.deepEqual(
missingActiveSessionSendAction([{ id: "r1", sessionType: "birth_time_rectification" }]),
{ openSessionId: "r1" },
);
assert.deepEqual(
missingActiveSessionSendAction([]),
{ notice: "请先选一条对话,或点上方「新建对话」。" },
);
assert.match(pageSource, /composerLocksAsRectification\(activeSession, sessions\)/);
assert.match(sendFn, /missingActiveSessionSendAction\(sessions\)/);
assert.match(sendFn, /openRectificationSession\(action\.openSessionId\)/);
assert.match(sendFn, /setComposerNotice\(action\.notice\)/);
assert.doesNotMatch(
sendFn,
/if \(!question \|\| !currentSession \|\| !modelCatalog \|\| !account\) return false;/,
);
});
@@ -82,7 +82,13 @@ test("entry feedback is static: card and sidebar row say 正在打开, and nothi
assert.doesNotMatch(starter, /InlineSpinner/); assert.doesNotMatch(starter, /InlineSpinner/);
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/); assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/); assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/); // 原值:page.tsx 把 openingSessionId 直接传给 AppSidebar
// 新值:layout 从 registration 传入,首页 registerShellControls 载荷带 openingSessionId
// 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到正在打开
const layout = read("../src/app/(app)/layout.tsx");
const shell = read("../src/hooks/use-home-shell-registration.ts");
assert.match(layout, /openingSessionId=\{registration\?\.openingSessionId \?\? null\}/);
assert.match(shell, /openingSessionId: rectificationOpeningSessionId,/);
// 原值 `.product-entrypoint-card[data-opening="true"]` / 新值 `.starter-entry[data-opening="true"]` // 原值 `.product-entrypoint-card[data-opening="true"]` / 新值 `.starter-entry[data-opening="true"]`
// / 原因:入口从卡片降级成 pill。「打开 Case 时只换指针、不出 spinner」这条不变。 // / 原因:入口从卡片降级成 pill。「打开 Case 时只换指针、不出 spinner」这条不变。
assert.match(styles, /\.starter-entry\[data-opening="true"\][^{]*\{ cursor: progress; \}/); assert.match(styles, /\.starter-entry\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
@@ -17,13 +17,17 @@ def _home_entrypoint_surface() -> str:
def test_daily_starlanguage_entrypoint_is_productized() -> None: def test_daily_starlanguage_entrypoint_is_productized() -> None:
# 原值:断言 daily-starlanguage-card(空状态大卡)
# 新值:StarterEntries 的 starter-entry pill + startDailyStarlanguageConsultation
# 原因:62903b7a 已把卡片改成问候 + 输入框 + 两枚 pill,全仓已无该类名
source = _home_entrypoint_surface() source = _home_entrypoint_surface()
assert "今日星语" in source assert "今日星语" in source
assert "fetchDailyStarlanguage" in source assert "fetchDailyStarlanguage" in source
assert "daily-starlanguage-card" in source assert "starter-entry" in source
assert "startDailyStarlanguageConsultation" in source assert "startDailyStarlanguageConsultation" in source
assert "从今日问起" in source assert "从今日问起" in source
assert "今天的星语还没写出来。" in source assert "今天的星语还没写出来。" in source
assert "daily-starlanguage-card" not in source
assert "buildDailyStarlanguageCard" not in source assert "buildDailyStarlanguageCard" not in source
assert "点这里" not in source assert "点这里" not in source
assert "不会用通用文案顶替" not in source assert "不会用通用文案顶替" not in source
@@ -40,10 +44,14 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
def test_birth_time_rectification_entrypoint_is_productized() -> None: def test_birth_time_rectification_entrypoint_is_productized() -> None:
# 原值:断言 birth-rectification-card
# 新值:同一套 starter-entry pill + openRectificationFromHomepage
# 原因:同上,卡片入口已删除
source = _home_entrypoint_surface() source = _home_entrypoint_surface()
assert "生时校正" in source assert "生时校正" in source
assert "birth-rectification-card" in source assert "starter-entry" in source
assert "openRectificationFromHomepage" in source assert "openRectificationFromHomepage" in source
assert "birth-rectification-card" not in source
def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None: def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None: