修复:动画期间内容变高时把回到底部动画重新对准新底部

- 程序化平滑滚动期间布局补偿不再直接跳过:内容变高时以新的 scrollHeight 重发平滑滚动
- 避免动画停在旧目标后 programmaticScrollRef 等不到贴底事件、跟随与补偿整段挂起
- hook 用例断言动画目标从 [1200] 变为 [1200,1500] 且仍能交还控制权
- ADR 第 3 条补一条口径,决策记录补齐本条与错误行结构两条

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 22:25:47 +08:00
parent 8f91227d17
commit 2b01476891
4 changed files with 34 additions and 2 deletions
@@ -439,6 +439,27 @@ describe('点「回到底部」后的程序化滚动', () => {
expect(counter.writes).toBe(writesBefore);
});
it('动画途中内容变高:把动画目标重新对准新的底部', () => {
const { metrics, smooth, view } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
expect(smooth.targets).toEqual([1200]);
// 点下去之后内容又长高了(图片撑开 / 流式正文):点击瞬间记下的 1200 已经不是底部。
metrics.scrollHeight = 1500;
FakeResizeObserver.triggerLayoutChange();
// 修正前:程序化滚动期间补偿直接跳过,动画停在旧目标 1200 上;`programmaticScrollRef` 只在
// 「贴底」那次滚动事件里交还,于是跟随与补偿永久挂起,胶囊又已按「已贴底」隐掉。
expect(smooth.targets).toEqual([1200, 1500]);
// 动画落到新底部后照常交还控制权:再往上滚仍然能出现胶囊。
metrics.scrollTop = 1500 - 400;
fireEvent.scroll(view.getByTestId('list'));
metrics.scrollTop = 400;
fireEvent.scroll(view.getByTestId('list'));
expect(view.getByTestId('capsule').textContent).toBe('回到底部');
});
it('动画落到位后交还控制权:再往上滚照样显示胶囊', () => {
const { metrics, view, list } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
@@ -151,8 +151,14 @@ export function useConversationScroll({
const list = listRef.current;
if (!list) return;
// 程序化滚动在飞:滚动位置归它所有,这里写一次就会把动画取消在半路。
if (programmaticScrollRef.current) return;
// 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。
// 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会
// 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿
// 一直挂着、胶囊又已按「已贴底」隐掉。重新对准新的底部,让动画继续跑到真正的底。
if (programmaticScrollRef.current) {
if (followLatestRef.current) scrollListToBottom(list, 'smooth');
return;
}
if (followLatestRef.current) {
scrollListToBottom(list, 'auto');
@@ -41,6 +41,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。
### 4. 跟随最新与折叠展开
@@ -14,6 +14,10 @@
- 验证(2026-10-02 追加,review 修正):新增一例控制器用例(驱动 A→B→A 且同路径新读取在飞,修正前在「旧读取落地」处红)与两例锚点缓存用例(连续收集只查一次 DOM、子元素变化并失效后重新收集);chat 范围用例全绿,`npm run ai-game-creator-shell:typecheck`、`eslint --max-warnings 0`、`npm run check:encoding`、`git diff --check` 通过。
- 追加(2026-10-02,锚点改用稳定块身份):review 第 3 条(`findTurnBlock` 按块序号定位)成立,采纳「换一套块身份」。锚点从 `{回合 key, 块序号, 偏移}` 改为 `{回合 key, 块身份, 偏移}`,块身份即 `DirectChatBlock.key`(`${回合 key}:${条目 itemId}` / 本地说明 `messageId`),只要求同一回合内唯一;展示层给每个可锚定块加 `data-block-key`(`DirectProjectTurn` 的正文块与终态文案、折进 `<details>` 的过程包装块、`ToolCallGroup`、`AgentReasoning` 各自透传),`conversationScrollAnchor.ts` 的收集选择器因此收敛为 `[data-turn-key][data-block-key]`。原因:`renderTurnProcess` 对运行中的回合平铺过程块、对已结束的回合折进一个 `<details>`,收口时整个回合的块序号后移一格,序号锚点会解析到隔壁块并按错误基准写 `scrollTop`。同时补上 review 指出、原方案漏掉的一半:锚点块没有布局盒(被折进收起的 `<details>`)时读锚点跳过它、`restoreTurnAnchor` 判为失败,调用方放弃这次补偿并重新起锚——不回跳,也不按别的块硬对齐。
- 验证(2026-10-02 追加,稳定块身份):锚点纯函数用例改为按块身份构造(`buildList` 传 `[回合 key, 块身份]`),新增「回合收口后块序号整体后移,块身份仍指向同一块且还原成功」「收起的 `<details>` 里的块没有布局盒:读锚点跳过、还原返回 false 且不写 `scrollTop`」;组件层新增用例断言每个带 `data-turn-key` 的块都有 `data-block-key`、同回合内块身份唯一、回合 running→finished 后同一块身份仍在。修正前锚点用例在「收口后按身份定位」处红。
- 追加(2026-10-02,review:动画期间内容变高会把程序化滚动标记卡住):`scrollToBottom` 把点击那一刻的 `scrollHeight` 当动画目标,而流式正文 / 图片撑开会让它在动画期间继续变高;`programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是动画停在旧目标后标记永远为真——布局补偿整段被跳过(列表不再跟随新内容),胶囊又已按「已贴底」隐掉,用户停在底部之上却没有任何指示和自动跟随。修正:程序化滚动期间布局补偿不写 `scrollTop`,但内容变高时把动画目标重新对准新的底部(`scrollListToBottom(list, 'smooth')`),动画继续跑到真正的底,标记照常在贴底时交还。
- 验证(2026-10-02 追加,动画目标重对准):`useConversationScroll.test.tsx` 新增一例——点击回到底部后内容变高(`scrollHeight` 1200→1500)触发一次布局变化,断言动画目标从 `[1200]` 变为 `[1200, 1500]`、落到新底部后仍能交出控制权;修正前在目标数组处红。
- 追加(2026-10-02,review:错误行的重试按钮在断言式 live region 内):`DirectProjectHistoryErrorRow` 原来把重试按钮渲染在 `<p role="alert">` 里面,而 `role="alert"` 隐含 `aria-live="assertive"` + `aria-atomic="true"`,交互控件会被卷进整段断言性播报、读屏也不一定把它当可聚焦按钮。修正:容器改为普通 `<div>`,`role="alert"` 只包住「加载更早对话失败」文案本身,重试按钮是 live region 之外的兄弟。
- 验证(2026-10-02 追加,错误行结构):`DirectProjectConversation.test.tsx` 新增一例断言 `role="alert"` 节点不包含重试按钮、且文案仍在 alert 内并仍可点击;修正前红。
## 2026-10-01 Web、后台与 AGC 一键联调