fix(chat): keep latest-turn actions under the reply (BUG-1042)

The BUG-930 pin spacer sat on the last assistant row, pushing its sibling
.message-actions (and follow-ups / delivery card) below half a screen of
empty space. Move the same min-height onto the last turn's entry
(`:last-child:has(.message-assistant)`) with align-content: start so the
rectification grid wrap does not stretch its rows. Hook logic unchanged.

Records BUG-1043 / BUG-1044 (pre-existing, investigating) found during
browser verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 10:19:00 +08:00
co-authored by Claude Opus 5.5
parent ac224cef6a
commit 83cce7ad6a
13 changed files with 189 additions and 7 deletions
+45
View File
@@ -13943,3 +13943,48 @@
- 相关记录:BUG-163(入口摘要由服务端 Case 驱动)、BUG-1040(暖快照缓存的正是解析后的 summary,快照结构不变)。
- 复发自:无。
- 修复版本:`codex/starter-home-polish-20260926`,未部署。
## BUG-1042 | 最后一轮回答与点赞 / 踩 / 复制 / 重试之间空出大半屏
- 状态:investigating(实现已完成、本地真实浏览器验证通过,等待 Claude 验收与 staging 部署后真机复核)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`frontend/src/app/globals.css` 末轮留白规则;普通咨询 `.message-entry`(`chat-transcript.tsx` `MessageEntry`)与生时校正 `.rectification-message-wrap`(`rectification-agentic-chat.tsx` + `rectification-message-entry.tsx`)两处最后一轮。
- 用户现象:产品 09-26 iPhone Safari 生时校正会话:助手正文结束后,点赞 / 踩 / 复制 / 重试一排被推到屏幕下部,中间空出大半屏。普通咨询同样存在,回答越短空白越大。
- 触发条件:本轮经 `pinLatestTurn` 钉顶(发问、校正点选项 / 开场)后,最后一轮回答短于一屏。
- 根因:BUG-930 的钉顶留白写成 `.conversation .message-list > :last-child .message-assistant { min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height)) }`,加在助手行本身;而 `.message-actions`(以及普通咨询的追问建议、校正的交付卡)是助手行之后、同一个 `:last-child` 容器里的兄弟节点,于是被顶到留白之后。实测(无头 Chrome 151,虚构数据)正文末行到操作行的距离:普通咨询 375px 526px / 1280px 608px;生时校正 375px 452px / 1280px 554px。
- 为何 BUG-930 验收没发现:当时的 Chrome 骨架页只验证「本轮开头钉在视口顶部」(S1–S4),骨架里没有操作按钮行,也没人看按钮落在哪里;合同测试只锁选择器字符串,不看操作行的位置。
- 修复:留白改加在整轮外层 `.conversation .message-list > :last-child:has(.message-assistant)`,计算式与两个 CSS 变量口径不变(`useConversationScrollAnchor` 逻辑未改,只更新了 `turnHeadHeightForSpacer` 的注释);外加 `align-content: start`,防止生时校正外层(`display: grid`)把多出来的高度分摊到各行、把间距又撑开。头行与末行同一元素时(BUG-931)仍写 `0px`:外层从这条助手行开始,最小高度等于整个视口正好把它留在顶部。
- 验证:`frontend/tests/chat-notice-and-scroll-contract.test.ts`「turn spacer uses conversation viewport variables and is not sticky」(选择器三栏改写 + 断言 `align-content: start`)与新增「the turn spacer trails the actions row…(BUG-1042)」(任何规则不得把 `--conversation-viewport` 写在 `.message-assistant` 上;两处 DOM 中操作行与助手行同在末轮外层且在其后)。本地 `next build --webpack` + `next start` + Chrome 151 CDP、`/api/*` 虚构数据:短回答正文末行到操作行 23px(即 `.message-actions` 自身间距),375 / 1280、普通咨询 / 生时校正四种组合一致;本轮开头仍停在滚动容器顶部下方约 10px;普通咨询长回答「跳到最新」出现、点击后落底并继续跟随。截图 `docs/testing/latest-turn-actions-gap-20260926/`。全量 3961 条 / 61 失败,失败名单与基线逐条一致(新增 1 条通过)。
- 防复发:留白只加在末轮外层;新增合同测试禁止把视口留白写回助手行。改末轮 DOM(给外层加兄弟、把操作行挪出外层)必须同时看操作行位置。
- 相关记录:BUG-930(钉顶留白来源)、BUG-931(头行即末行时写 0)、BUG-932(闲置不写留白);验证中另查出 BUG-1043、BUG-1044(均为既有问题,与本次改动无关,前后表现一致)。
- 复发自:无(BUG-930 的遗漏)。
- 修复版本:`codex/latest-turn-actions-gap-20260926`,未部署。
## BUG-1043 | 直接打开普通咨询会话后,滚动锚的监听从未挂上
- 状态:investigating(仅诊断;本轮任务书禁止改 `useConversationScrollAnchor`,未修)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`frontend/src/hooks/use-conversation-scroll-anchor.ts` 两个 effect(scroll 监听、ResizeObserver 跟随)与首页 `page.tsx` 的加载闸门;普通咨询面。生时校正面不受影响(组件在揭幕后才挂载)。
- 用户现象(本地复现):刷新页面或经 `?c=` / 启动落点直接进入一个已有的普通咨询会话后,发一个短问题,回答很短也会出现「跳到最新」;上下滚动该按钮不消失;点它之后回答继续变长也不会跟随。
- 触发条件:首页揭幕时当前会话就是一条有消息的普通咨询(不是揭幕后再切换过去)。
- 已确认事实:无头 Chrome 151、生产构建、虚构数据下给 `EventTarget.addEventListener` 与 `ResizeObserver.observe` 打点:该路径下 `.conversation` 上 scroll 监听 0 次、ResizeObserver 观察 0 次;先开 `/?new=1` 再点侧栏进入同一会话,则各挂上 1 次,短回答不再出现「跳到最新」。`pinLatestTurn` 走 rAF 不依赖 effect,所以钉顶本身正常,但 `latestBelowFold` 停在钉顶那一刻算出的值。
- 推测根因(未证实):加载闸门期间 `page.tsx` 早退渲染 `.app-loading`,`.conversation` 尚未挂载,effect 因 `container.current` 为空直接返回;揭幕后 `active` 与 `resetKey`(`activeSessionId`)都没变,effect 不会重跑。
- 修复:未修。需要单独任务书授权改 hook(例如依赖里加入容器是否已挂载的状态)。
- 验证:见上,复现脚本在执行方 scratchpad,未入库。
- 防复发:待定。
- 相关记录:BUG-930、BUG-932、BUG-1042。
- 复发自:无。
- 修复版本:无。
## BUG-1044 | 生时校正长回答钉顶后又被拉到底部
- 状态:investigating(仅诊断;本轮任务书禁止改 `useConversationScrollAnchor`,未修)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`useConversationScrollAnchor` 的 `holdUnpin` 解除条件 × 生时校正 `.message-list` 的底部留白(`padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3))`)。
- 用户现象(本地复现):生时校正里发一句话,回答超过一屏时,本轮开头先被钉到顶部,回答一到视口就跳到最后一个字(BUG-930 想解决的现象)。
- 已确认事实:CDP 逐 100ms 采样:平滑滚动结束时本轮开头距顶 10px、`distanceFromBottom` = 94(≤ `conversationAnchorThreshold` 96),下一帧回答撑高后 `scrollTop` 直接到底。改动前(旧留白规则)表现完全相同(开头位置 −1323px,落底),与 BUG-1042 无关。普通咨询同一时刻距离约 198,不触发。
- 推测根因(未证实):钉顶后的静止距离等于该面底部留白 + 钉顶间距,校正面恰好 ≤ 96,`measure()` 把它当成「读者回到底部」,解除 `holdUnpin` 并把 `anchored` 翻回真,随后 ResizeObserver 贴底跟随。BUG-931 修的是留白坍塌导致的同一症状,这里留白正常但阈值判断仍然命中。
- 修复:未修。需单独任务书决定改阈值判定还是改该面的留白口径。
- 相关记录:BUG-930、BUG-931、BUG-1042。
- 复发自:无(BUG-931 同症状、不同触发条件)。
- 修复版本:无。
@@ -0,0 +1,92 @@
# PROGRESS · 最后一轮回答与操作按钮之间的大段空白(2026-09-26)
任务书:`TASK-latest-turn-actions-gap-20260926.md`。执行方:Claude 子代理(直接执行模式)。分支 `codex/latest-turn-actions-gap-20260926`,worktree `.worktrees/latest-turn-actions-gap-20260926`,本地提交未推送。
## 基线
- `origin/staging` = `a57c4310`。
- 基线全量前端测试(Node 20.19.2):3960 条,pass 3872 / fail 61(均为环境缺口:Node 20 `mock.module` 的 `ERR_MODULE_NOT_FOUND @/…`、无 Docker)。
- 基线 rootMainFiles gzip 130933 B。
- BUG 编号:开工时 `docs/BUG_HISTORY.md` 最大 BUG-1041,`docs/tasks/` 无更大号 → 本单 BUG-1042;验证中另查出的两个既有问题记为 BUG-1043、BUG-1044(investigating,未修)。
## DOM 核对(D2)
| 面 | 最后一轮的 `.message-list > :last-child` | 内容 | 用户行 |
| --- | --- | --- | --- |
| 普通咨询 | `div.message-entry`(`chat-transcript.tsx` `MessageEntry`) | `article.message-assistant` → `.message-actions` → `.conversation-follow-ups` | 单独一个 `.message-entry`,不在末轮外层里 |
| 生时校正 | `div.rectification-message-wrap.rectification-message-entry`(`display: grid; gap: var(--space-1)`) | `article.message-assistant` → `.message-actions` → 交付卡 / 只读区间(若有) | 单独一个 wrap,不在末轮外层里 |
两处外层都**从助手行开始**、都不含用户行,所以 `--latest-turn-head-height` 的口径原样可用:头是用户行时扣用户行高度;头就是这条助手行(BUG-931)时写 `0px`,外层最小高度 = 整个视口,正好把它留在顶部。hook 不需要改逻辑。
末轮外层不是助手外层的情况(校正面的 persisted-question / pending-note / error 是最后一个子节点、普通咨询 `error-message`):旧规则 `:last-child .message-assistant` 也匹配不到,新规则 `:last-child:has(.message-assistant)` 同样不匹配,行为不变。
## 改动
| 文件 | 内容 |
| --- | --- |
| `frontend/src/app/globals.css` | `.conversation .message-list > :last-child .message-assistant { min-height: … }` → `.conversation .message-list > :last-child:has(.message-assistant) { min-height: …; align-content: start; }`。计算式、变量不变。`align-content: start` 是必要的:生时校正外层是 grid,给了最小高度后 `align-content: normal` 会把多余高度分摊给各行,操作行又会离开正文。 |
| `frontend/src/hooks/use-conversation-scroll-anchor.ts` | 只改 `turnHeadHeightForSpacer` 的注释(留白承载者从助手行变为末轮外层,BUG-931 写 0 的理由随之更新)。逻辑未动。 |
| `frontend/tests/chat-notice-and-scroll-contract.test.ts` | 见下「改动既有断言」;新增 1 条。 |
| `frontend/DESIGN.md` | Answer start anchor · Spacer 条目改写。 |
| 记录 | `docs/BUG_HISTORY.md`(BUG-1042 / 1043 / 1044)、`CHANGELOG.md`、`docs/testing/latest-turn-actions-gap-20260926.md` + 4 张截图、`docs/tasks/README.md`。 |
`:has()` 兼容性:Safari 15.4+ / Chrome 105+ / Firefox 121+,低于本站语法下限 Safari 16.4;`globals.css` 已有多处 `:has()`。
### 改动既有断言(三栏也写在测试文件内)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 「turn spacer uses conversation viewport variables and is not sticky」 | 选择器 `.conversation .message-list > :last-child .message-assistant` 带该 `min-height` 计算式 | 选择器 `.conversation .message-list > :last-child:has(.message-assistant)` 带同一计算式,并断言 `align-content: start` | BUG-1042:留白从助手行移到整轮外层;计算式与变量口径不变,`sticky` 否定断言保留,变量写入 / 清除的行为断言原样保留 |
新增「the turn spacer trails the actions row instead of sitting between text and actions (BUG-1042)」:任何 `.message-assistant { … }` 规则里不得出现 `--conversation-viewport`;普通咨询 `MessageEntry` 里 `ChatMessageRow` → `ChatMessageActions` → `ConversationFollowUps` 顺序同在 `.message-entry`;校正面 `ChatMessageRow` 在 `ChatMessageActions` 之前且 `RectificationMessageEntry` 直接挂在 `.rectification-message-wrap` 里。
BUG-930 / 931 / 932 的其余断言(`resolveTurnHead`、头即尾写 0、闲置不写留白、`pinLatestTurn` 源码断言、`lastTurnTail` 假 DOM)一条未改。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(126 warning,均为既有) |
| 全量 `npm test` | 3961 条,pass 3873 / fail 61 |
| 失败名单 vs 基线 | 逐条一致,新增失败 0、修好 0 |
| 测试名单 vs 基线 | +1 新名(上面新增那条),消失 0 |
| Python 源码合同 | `test_session_management_entrypoints.py`、`test_daily_and_rectification_entrypoints.py`、`test_birth_time_journey_contract.py` 全过;`test_frontend_theme_contract.py::test_lightweight_warm_palette_contract` 失败,**改动前同样失败**(`--color-ink-secondary` 色值不符,与本单无关,未动) |
| `npm run build -- --webpack` | 通过;`/` ○ Static |
| gzip | rootMainFiles 130933 → 130933 B(0,本单只改 CSS);`index.html` 引用的全部 js/css 合计 656748 B |
## 真实浏览器验证
环境:本地 `next build --webpack` + `next start`,无头 Chrome 151(`--headless=new --disable-dev-shm-usage`),CDP `Fetch` 拦截 `/api/*` 回虚构数据(5 轮虚构历史撑出一屏以上;普通咨询 `/api/consult` 回纯文本,校正 `/api/rectification/agent` 回 ndjson `answer.delta` + `run.completed`)。「改动前」= 同一构建里注入旧规则(新规则 `min-height: 0 !important` + 旧选择器原样),因为本单只改了这一条 CSS。输入框用 CDP `Input.insertText` + Enter 真发问,钉顶由真实 `pinLatestTurn` 完成。
「正文末行 → 操作行」= `.message-actions` 顶边 − 末轮回答文本节点最后一行的底边。「开头距顶」= 本轮用户行顶边 − 滚动容器顶边。
| 面 | 宽度 | 改动前 间距 | 改动后 间距 | 改动后 开头距顶 | 改动后 外层高度 / 助手行高度 |
| --- | --- | --- | --- | --- | --- |
| 普通咨询 短回答 | 375 | 526px(操作行在视口外) | **23px** | 10px | 589 / 86 |
| 普通咨询 短回答 | 1280 | 608px(操作行在视口外) | **23px** | 10px | 671 / 86 |
| 生时校正 短回答 | 375 | 452px(且开头被顶出视口,见下) | **23px** | 10px | 495 / 66 |
| 生时校正 短回答 | 1280 | 554px(同上) | **23px** | 10px | 597 / 66 |
23px 即操作行自身间距:同一页面里历史轮(非末轮,从未带留白)的同一量测得也是 23px(普通咨询、生时校正各测一次)。
长回答(14 段):
- 普通咨询 375 / 1280:开头距顶 10 / 9px,「跳到最新」出现;点击后落底(`distanceFromBottom` 0)、按钮消失、操作行可见;随后往末轮插入 400px 内容,仍保持落底(跟随正常)。
- 生时校正 375 / 1280:本轮开头**没有**停在顶部,回答一到就落底——改动前完全相同(开头位置同为 −1323 / −482px)。定性为既有问题 BUG-1044,见下。
截图(入库,375 宽):`docs/testing/latest-turn-actions-gap-20260926/consult-375-{before,after}.png`、`rectification-375-{before,after}.png`。其余(1280、长回答、跳到最新后)在执行方 scratchpad `gap/`,未入库。校正截图底部的「合盘历史暂时无法读取」提示是虚构数据没回合盘接口所致,与本单无关。
附带改善:改动前校正面短回答结算时,操作行追加在已撑满视口的助手行之后,内容变高触发贴底跟随,把本轮开头顶出视口(上表「改动前」开头距顶 −118px);改动后操作行落在外层的最小高度之内,内容总高不变,开头留在顶部。
## 验证中查出的既有问题(未修,前后表现一致)
1. **BUG-1043**:刷新或经 `?c=` / 启动落点直接进入已有消息的普通咨询会话时,`useConversationScrollAnchor` 的 scroll 监听与 ResizeObserver 一次都没挂上(给 `addEventListener` / `ResizeObserver.observe` 打点实测为 0),结果短回答也显示「跳到最新」、滚动不更新、点击后不跟随。先 `/?new=1` 再点侧栏进入同一会话则正常(本单的普通咨询测量都走这条路径)。推测:加载闸门期间容器未挂载、effect 早退,揭幕后依赖不变不重跑。
2. **BUG-1044**:生时校正钉顶后静止时 `distanceFromBottom` = 94 ≤ 阈值 96,`measure()` 当成「回到底部」解除 `holdUnpin`、`anchored` 翻真,长回答随后贴底。普通咨询同一时刻约 198,不触发。
两条都在 hook 里,任务书红线禁止改 hook,只记为 investigating,建议另开单。
## 未做 / 环境缺口
- 登录态 iPhone Safari 真机:留给 `docs/testing/latest-turn-actions-gap-20260926.md`。
- 未部署、未推送。
+1
View File
@@ -124,6 +124,7 @@
| `TASK-scroll-anchor-hook-fixes-20260926.md` | — | **滚动锚两处老问题**:直接打开已有会话时监听未挂上(BUG-1043)、校正长回答钉顶后因 96px 阈值被拉到底(BUG-1044)。排在 BUG-1042 合入后 | 待领取 | — |
| `TASK-latest-turn-actions-gap-20260926.md` | — | **最后一轮正文与点赞 / 踩之间空大半屏**:BUG-930 钉顶留白(`min-height: 视口 − 本轮开头`)加在 `.message-assistant` 上,把兄弟节点 `.message-actions` 推到留白之后;改为加在整轮外层,按钮紧贴正文、空白落在后面;不动滚动 hook | 待领取 | — |
| `TASK-starter-home-polish-20260926.md` | `PROGRESS-starter-home-polish-20260926.md` | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后。分支误判实为入口摘要解析读错键名(BUG-1041) | 已验收(Claude 09-26 直接执行:子代理实现并查出 BUG-1041 入口摘要驼峰/下划线字段不一致;Claude 改恢复提示文案为「可以在历史对话里接着做」;独立复验 tsc/lint 0、全量 3960 条失败名单与基线逐条一致、四路由 ○、gzip 不变) | `f04da103`(已部署 `62d4c9c4`,health 一致) |
| `TASK-latest-turn-actions-gap-20260926.md` | `PROGRESS-latest-turn-actions-gap-20260926.md` | **末轮回答与点赞一排之间大段空白**:BUG-930 的钉顶留白加在助手行上,把其后的 `.message-actions` 顶到留白之后;改加在整轮外层(`:last-child:has(.message-assistant)` + `align-content: start`),不改 hook。验证中另查出既有 BUG-1043(直接进入普通咨询会话时滚动锚监听未挂上)、BUG-1044(校正长回答钉顶后被拉到底),未修 | 待验收 | `codex/latest-turn-actions-gap-20260926`(BUG-1042) |
| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 已验收(Claude 09-26 直接执行:子代理实现,Claude 独立复验 tsc/lint 0、全量 3953 条失败名单与基线逐条一致、四路由 ○、gzip 不变;CDP 实测回首页 5.5 s → ~20 ms、无加载环) | `638b6a60`(已部署 `7475eb6e`,health 一致) |
| `TASK-new-chat-from-people-20260926.md` | `PROGRESS-new-chat-from-people-20260926.md` | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 已验收(Claude 09-26 直接执行:子代理复现并定位 sessionStorage 登录返回存根;Claude 独立复验 tsc/lint 0、全量 3928 条失败名单与基线逐条一致、四路由 ○、gzip 不变、修复前后截图核对) | `0a8350cc`(已部署 `6a22626d`,health 一致) |
| `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 已验收(Claude 09-25:两 P0、两 P1、七 P2 通过;新 P2 删后跳错人、人物接口故障连累聊天 → 收尾单) | `4d801e53`(已部署,health 核对一致) |
@@ -0,0 +1,11 @@
# 回答与操作按钮间距 · 真机清单(2026-09-26)
本轮在本地 `next start` + 无头 Chrome 里用虚构接口数据验证并截图(`latest-turn-actions-gap-20260926/`,375 宽改动前后各一张),没有登录态真机。部署到 staging 后请用自己的账户在 iPhone Safari 上照做:
1. 打开一个已有几轮内容的普通对话,问一个很短就能答完的问题(比如「谢谢」之后再问「今天适合出门吗」)。回答结束后:点赞 / 踩 / 复制 / 重试一排**紧贴在回答最后一行下面**,中间没有大片空白;你刚问的那句话停在屏幕顶部附近。
2. 同一个对话里有「可以继续问」的建议按钮时:它们也紧跟在点赞一排下面,空白在它们之后。
3. 打开一个生时校正会话,打一句简短的经历发出去。助手回复结束后同样:操作按钮紧贴回复,空白在按钮下面。
4. 问一个会得到长回答的问题:回答超过一屏时底部出现「跳到最新」,点一下滚到最后,点赞一排在最后一段下面。
5. 以上在横屏或桌面浏览器(宽屏)各看一次,表现一致。
已知、与本轮无关(已记为 BUG-1043 / BUG-1044,未修):刷新后直接回到普通对话再发问,短回答下也可能出现「跳到最新」;生时校正里长回答可能直接滚到最后一个字,而不是停在开头。看到这两种情况请记下,不算本轮不通过。
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB