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

13 KiB
Raw Blame History

任务书 · 回答落在开头而不是结尾:两个会话面滚动改为「本轮开头钉在顶部、回答向下长」(2026-09-17)

0. 基线

  • 基线 commitf6db3c77origin/staging head;代码基线仍是 dc2f2a16,其后只有文档)。
  • 分支:codex/consultation-answer-start-anchor-20260917git worktree add -b codex/consultation-answer-start-anchor-20260917 .worktrees/consultation-answer-start-anchor-20260917 origin/staging
  • 范围:frontend/src/hooks/use-conversation-scroll-anchor.tsfrontend/src/hooks/use-consultation-run.ts(一处调用改名)、frontend/src/components/jump-to-latest-button.tsxfrontend/src/app/globals.cssfrontend/DESIGN.mdfrontend/tests/chat-notice-and-scroll-contract.test.tspage.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.tsxjumpToLatestVisible = … && !conversationAnchor.anchored && messages.length > 0,「跳到最新」只在读者主动上滑离开底部 96px 之后出现;回答本身长出视口不算。
  • 主会话回答的 DOM 顺序(chat-message-row.tsx):.message-assistant.message-bubble → 先 .consultation-thinking-report(思考块)再 .consultation-report-analysis(回答正文)。「回答开头」在视觉上就是用户问题行的下一块
  • 生时校正面用同一个 hookrectification-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/252sticky 与 padding-bottom 的老坑:留白不得用 sticky 实现)。推翻 BUG-041 / BUG-048 的「生成中即时跟随到底部」,改为「每轮定位到本轮开头」;两条记录在 Bug 历史里补一行「2026-09-17 语义被 BUG-930 取代」,不改旧记录正文。
  • AGENTS.md §6:不得出现第二套滚动跟随;揭幕后不得出现 spinner / 骨架。

4. 硬红线

  1. 滚动逻辑只在 useConversationScrollAnchor 内;page.tsxuse-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 变化(切换会话 / 换案例)仍先落底一次,然后置假。
    • ResizeObserverfollow() 只在 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.tsxjumpToLatestVisiblerectification-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、不得改 .conversationpadding-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 的 Visibilitydocs/tasks/PROGRESS-consultation-answer-start-anchor-20260917.md;状态板行。
  • docs/testing/ 真机条目:① 问一个会有长回答的问题(例如「详细分析我的事业格局」):发送后问题行在顶部、回答从它下面开始、视口不动;② 回答超过一屏后出现「跳到最新」,按下贴底并跟随到结束;③ 短回答(一两句)问题仍在顶部、下方留白、不抖动;④ 切换到别的会话落在底部;⑤ 生时校正面:打字提交、点选项、开场三种情况下本轮开头都在顶部,长旁白时出现「跳到最新」、按下能看到选择卡;⑥ iPhone 键盘弹起收起后位置不跳(配合 BUG-920 单一起看)。

6. 让步顺序

T1 > T2 > T3 > T4。T3 若在 iOS Safari 上留白引起抖动,可退为「留白只在回答未结束时存在,结算后移除」,写进进度记录。校正面与主会话不得让步为两种语义。

7. 开工前置命令

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 errornpm test 失败清单与基线逐条一致、新增测试全绿、总数不降;next build/ 仍 Static;首屏 gzip ±2%page.tsx 行数 ≤ 开工时;校正面滚动测试按新语义全绿。真机六条由产品负责人在 staging 走。