Files
Jyotisha/docs/tasks/TASK-consultation-answer-start-anchor-20260917.md
T

102 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 回答落在开头而不是结尾:两个会话面滚动改为「本轮开头钉在顶部、回答向下长」(2026-09-17)
## 0. 基线
- 基线 commit:`f6db3c77`(`origin/staging` head;代码基线仍是 `dc2f2a16`,其后只有文档)。
- 分支:`codex/consultation-answer-start-anchor-20260917`,`git worktree add -b codex/consultation-answer-start-anchor-20260917 .worktrees/consultation-answer-start-anchor-20260917 origin/staging`。
- 范围:`frontend/src/hooks/use-conversation-scroll-anchor.ts`、`frontend/src/hooks/use-consultation-run.ts`(一处调用改名)、`frontend/src/components/jump-to-latest-button.tsx`、`frontend/src/app/globals.css`、`frontend/DESIGN.md`、`frontend/tests/chat-notice-and-scroll-contract.test.ts`、`page.tsx` 只允许改 hook 调用参数(不增行);`frontend/src/components/rectification-agentic-chat.tsx`(提交与新轮到达处各加一次 `pinLatestTurn()` 调用)及其滚动相关测试。**两个会话面同一语义。**
- 与同日其他单的关系:不碰 `use-session-management.ts`、不碰路由组,可与 composer-guard 单并行;若 session-list 单先合入且把 `page.tsx` 移到 `(app)/page.tsx`,本单在新路径上改。
- BUG 段:**BUG-930 起**(924–929 已被同日两单占用;开工时核对 `docs/BUG_HISTORY.md` 最大号)。
## 1. 事故实证
产品负责人 2026-09-17 反馈:在主会话问一个问题,模型输出一大段回答,视图直接滚到结尾;要读回答得不断上滑找开头。
代码定位(按符号):
- `frontend/src/hooks/use-conversation-scroll-anchor.ts` `useConversationScrollAnchor`:语义是「贴底跟随」。`anchored` 初始为真;第二个 effect 用 `ResizeObserver` 观察滚动容器的直接子元素,任何高度变化在下一帧执行 `element.scrollTop = element.scrollHeight`。流式输出期间每一帧内容都在长,视口因此一直钉在**最新一个字**上,回答开头在流结束时早已滚出视口顶部。
- `frontend/src/hooks/use-consultation-run.ts` `send()`:用户消息乐观入列后调用 `conversationAnchor.anchorToLatest()`,把 `anchored` 强制置真并 `scrollTo` 底部——即使读者刚才手动上滑,新一轮也重新贴底。
- `frontend/src/app/page.tsx`:`jumpToLatestVisible = … && !conversationAnchor.anchored && messages.length > 0`,「跳到最新」只在读者**主动上滑**离开底部 96px 之后出现;回答本身长出视口不算。
- 主会话回答的 DOM 顺序(`chat-message-row.tsx`):`.message-assistant` → `.message-bubble` → 先 `.consultation-thinking-report`(思考块)再 `.consultation-report-analysis`(回答正文)。「回答开头」在视觉上就是**用户问题行的下一块**。
- 生时校正面用同一个 hook(`rectification-agentic-chat.tsx` 第 469 行附近,`resetKey = caseId`,没有自己的 `anchorToLatest` 调用,完全靠 hook 的贴底跟随)。它每一轮是旁白 + 题目 + 选择卡,卡在末尾;长旁白同样把本轮开头推出视口。用户打字或点「发送」会生成 `role: "user"` 行;点选项不生成用户行,新一轮直接以助手行开始;开场轮也没有用户行。BUG-041 / BUG-048 当年要求校正面「生成中即时跟随到底部」。
Bug 历史检索:BUG-478 把两个会话面的滚动跟随并成这一个 hook,防复发是「跟随只经 `useConversationScrollAnchor`,跳到最新只经 `JumpToLatestButton`」,本单保持;BUG-041 / BUG-048 要求校正面贴底,**本单按产品决策推翻这两条的跟随语义**(见 §3),它们「必须验证容器自身 `scrollTop`」「新增滚动容器必须覆盖三类触发源」的测试要求保留。本单不是复发,是两个面的**跟随语义**本身要改,且必须在同一个 hook 里改。
## 2. 根因
主会话把「让读者看到最新内容」实现成「视口永远钉在最后一个字」。对一两句话的回答两者等价;对一屏以上的回答,读者真正需要的是**从回答开头开始读**,而贴底把开头推出了视口。claude.ai / ChatGPT 的做法都是:发送后把用户的问题滚到视口顶部,回答在它下面向下生长,视口不动;回答长出视口时用「跳到最新」把主动权交还读者。
## 3. 决策记录
- 产品负责人 2026-09-17 提出并授权:主会话改为**定位到回答开头**;同日追加拍板:**生时校正面也改**,两个面同一语义,不留贴底模式。
- Claude 定口径:
- **两个面同一规则**:新一轮开始时把**本轮开头**滚到滚动容器顶部(留 `space-4` 顶边距),之后**流式期间不再跟随**;内容在它下方向下生长。本轮开头 = 这一轮的用户行;这一轮没有用户行(校正面点选项、开场轮、服务端自动续轮)就是新到达的助手行顶部。内容超过视口时显示「跳到最新」,按下才贴底并恢复跟随到本轮结束。读者自己滚到底部(96px 内)也恢复跟随,与现在一致。
- 为了让短回答也能把问题钉到顶部,滚动容器末尾需要一段**动态留白**(最后一轮的最小高度 = 容器可视高度 − 问题行高度),留白保留到下一轮发送为止;这是 claude.ai / ChatGPT 的同一做法,不算「空白页」。
- **切换会话 / 打开历史**:仍落在底部(最新内容);这一点不变。
- **生时校正面**:选择卡不再保证一开始就在视口里;旁白长时读者读完旁白往下滚或按「跳到最新」到卡。这是产品明确接受的取舍。hook 不加模式参数,两个面调同一套;不得写第二套滚动逻辑(BUG-478 红线)。
- **思考块**:回答开头定义为用户问题行下面的第一块。若开工核实发现 `.consultation-thinking-report` 在流式期间或结算后**默认展开**且高度不可控,改为默认折叠、只留一行状态;若已是折叠/单行,不动。
- 不推翻 BUG-478(单一 hook、单一按钮)、BUG-218/252(sticky 与 `padding-bottom` 的老坑:留白不得用 sticky 实现)。**推翻 BUG-041 / BUG-048 的「生成中即时跟随到底部」**,改为「每轮定位到本轮开头」;两条记录在 Bug 历史里补一行「2026-09-17 语义被 BUG-930 取代」,不改旧记录正文。
- AGENTS.md §6:不得出现第二套滚动跟随;揭幕后不得出现 spinner / 骨架。
## 4. 硬红线
1. 滚动逻辑只在 `useConversationScrollAnchor` 内;`page.tsx`、`use-consultation-run.ts`、消息组件不得自己 `scrollTo` / 监听 scroll。
2. `birth-time-mobile-scroll-contract`「welcome content starts at the scroll origin」保持绿(开场轮在顶部与新语义一致);`rectification-candidate-offer-anchor` 是数据挂载测试,不涉滚动,不动。校正面既有的贴底断言按三栏改写成「本轮开头在顶部」。
3. `prefers-reduced-motion` 继续尊重;scroll 监听继续 passive。
4. 既有断言改动写「原值 / 新值 / 原因」三栏;`chat-notice-and-scroll-contract.test.ts` 里「anchors the streaming scroll instead of following every token」等条目按新语义改写而不是删除。
5. `page.tsx` 不增行;`globals.css` 不新造常数(留白用现有 `--composer-reserve` / `space-*`)。
## 5. 任务分解
### T1 hook 改语义:从贴底跟随到钉住本轮开头(BUG-930)
- `useConversationScrollAnchor(container, active, resetKey)` 签名不变,语义改:
- `anchored` 初始为假;`resetKey` 变化(切换会话 / 换案例)仍先落底一次,然后置假。
- `ResizeObserver` 的 `follow()` 只在 `anchored` 为真时贴底(逻辑不变,只是初值不同)。
- 新增 `pinLatestTurn(target?: HTMLElement)`:不传参时取容器内**最后一个 `.message-user`**,若它后面已经有助手行(说明这一轮没有用户行)则取最后一个助手行;`scrollTo({ top: row.offsetTop - space4 })`,并把 `anchored` 置假。`anchorToLatest()` 保持贴底并置真。
- 主会话:`use-consultation-run.ts` `send()` 里 `anchorToLatest()` 改为 `pinLatestTurn()`(乐观用户消息入列后的下一帧执行,保证行已在 DOM);排队草稿发出时同样。
- 校正面:`rectification-agentic-chat.tsx` 在(a)用户提交文字、(b)点选项、(c)新助手轮到达且本轮没有用户行(开场、自动续轮)三处调用 `pinLatestTurn()`;每轮只调一次,流式增量不再调。
- 验收(`chat-notice-and-scroll-contract.test.ts` 与校正面滚动测试,源码合同 + jsdom 行为):
- 模拟流式增高 5 次,`scrollTop` 保持在本轮开头位置不变;`anchorToLatest()` 后 `scrollTop === scrollHeight - clientHeight` 且继续跟随。
- `send()` 调用的是 `pinLatestTurn` 而不是 `anchorToLatest`(源码断言);`rectification-agentic-chat.tsx` 含三处 `pinLatestTurn()` 且不含 `scrollTo`(源码断言)。
- 校正面 jsdom:点选项后新助手行顶部在视口顶部;开场轮在顶部。
### T2 「跳到最新」在回答长出视口时出现(BUG-930)
- `page.tsx` 的 `jumpToLatestVisible` 与 `rectification-agentic-chat.tsx` 第 1815 行附近的 `!conversationAnchor.anchored` 都改为 hook 返回的 `latestBelowFold`(滚动容器底部距离 > 96px **且**本轮有新内容或正在流式)决定,不再只看「读者上滑过」。按下后贴底并恢复跟随。
- 视觉不变(`DESIGN.md` Jump to latest 一节的结构、位置、表面都不动),只改 Visibility 一条的文案。
- 验收:jsdom 行为测试:流式增高到超出视口后 `latestBelowFold` 为真;按下 `anchorToLatest()` 后为假且 `scrollTop` 贴底;读者自己滚回 96px 内也为假。
### T3 末尾动态留白(BUG-930)
- 两个面的滚动容器最后一轮(最后一个助手行,或流式占位)加 `min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height))`,两个变量由 hook 在 `ResizeObserver` 回调里写到容器 `style`。不得用 sticky、不得改 `.conversation` 的 `padding-bottom`。校正面的时间轴条在滚动容器之外(DESIGN.md 时间轴一节),不参与计算。
- 留白保留到下一轮发送;切换会话时清零。
- 验收:源码合同:`globals.css` 含该规则且不含新增 sticky;jsdom:短回答(一行)发送后 `scrollTop` 等于问题行 `offsetTop - space4`(说明留白足够把问题顶到顶部)。
### T4 思考块核实(条件任务)
- 开工时核实 `.consultation-thinking-report` 的默认展开状态与流式期间高度。若默认展开或高度随流式无界增长:改为默认折叠、只留一行状态,展开由读者点击;写进进度记录并更新 `DESIGN.md`。若已是折叠 / 单行:在进度记录里写「核实:默认折叠,未改」。
### T5 记录
- `docs/BUG_HISTORY.md` 新增 BUG-930(关联 BUG-478;写明取代 BUG-041 / BUG-048 的跟随语义,并在那两条末尾各补一行指回 BUG-930);`CHANGELOG.md` 一句「回答从开头开始读:发送后问题钉在顶部,回答向下生长,长出视口时显示跳到最新」;`frontend/DESIGN.md` 新增「Answer start anchor」小节并改 Jump to latest 的 Visibility;`docs/tasks/PROGRESS-consultation-answer-start-anchor-20260917.md`;状态板行。
- `docs/testing/` 真机条目:① 问一个会有长回答的问题(例如「详细分析我的事业格局」):发送后问题行在顶部、回答从它下面开始、视口不动;② 回答超过一屏后出现「跳到最新」,按下贴底并跟随到结束;③ 短回答(一两句)问题仍在顶部、下方留白、不抖动;④ 切换到别的会话落在底部;⑤ 生时校正面:打字提交、点选项、开场三种情况下本轮开头都在顶部,长旁白时出现「跳到最新」、按下能看到选择卡;⑥ iPhone 键盘弹起收起后位置不跳(配合 BUG-920 单一起看)。
## 6. 让步顺序
T1 > T2 > T3 > T4。T3 若在 iOS Safari 上留白引起抖动,可退为「留白只在回答未结束时存在,结算后移除」,写进进度记录。校正面与主会话不得让步为两种语义。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/consultation-answer-start-anchor-20260917 .worktrees/consultation-answer-start-anchor-20260917 origin/staging
cd .worktrees/consultation-answer-start-anchor-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线失败清单与测试总数
grep -n "^## BUG-" ../docs/BUG_HISTORY.md | tail -1
```
## 8. 验收口径
`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败清单与基线逐条一致、新增测试全绿、总数不降;`next build` 后 `/` 仍 Static;首屏 gzip ±2%;`page.tsx` 行数 ≤ 开工时;校正面滚动测试按新语义全绿。真机六条由产品负责人在 staging 走。