From 2b014768911ce25afd8535005ffc1c23aabf2b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 22:25:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E6=9C=9F=E9=97=B4=E5=86=85=E5=AE=B9=E5=8F=98=E9=AB=98=E6=97=B6?= =?UTF-8?q?=E6=8A=8A=E5=9B=9E=E5=88=B0=E5=BA=95=E9=83=A8=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=AF=B9=E5=87=86=E6=96=B0=E5=BA=95=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 程序化平滑滚动期间布局补偿不再直接跳过:内容变高时以新的 scrollHeight 重发平滑滚动 - 避免动画停在旧目标后 programmaticScrollRef 等不到贴底事件、跟随与补偿整段挂起 - hook 用例断言动画目标从 [1200] 变为 [1200,1500] 且仍能交还控制权 - ADR 第 3 条补一条口径,决策记录补齐本条与错误行结构两条 Co-authored-by: Junie --- .../useConversationScroll.test.tsx | 21 +++++++++++++++++++ .../useConversationScroll.ts | 10 +++++++-- ...tProject对话滚动与历史自动加载-2026-10-02.md | 1 + .../shared-memory/decision-log.md | 4 ++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx index 89b95285b..620ec09f2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.test.tsx @@ -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')); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts index 5031106b0..ca00bc8fc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts @@ -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'); diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md index 3065b8b6b..c0391602a 100644 --- a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -41,6 +41,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。 - 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。 - 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。 +- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。 ### 4. 跟随最新与折叠展开 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cde1d105c..53e5e873f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 的正文块与终态文案、折进 `
` 的过程包装块、`ToolCallGroup`、`AgentReasoning` 各自透传),`conversationScrollAnchor.ts` 的收集选择器因此收敛为 `[data-turn-key][data-block-key]`。原因:`renderTurnProcess` 对运行中的回合平铺过程块、对已结束的回合折进一个 `
`,收口时整个回合的块序号后移一格,序号锚点会解析到隔壁块并按错误基准写 `scrollTop`。同时补上 review 指出、原方案漏掉的一半:锚点块没有布局盒(被折进收起的 `
`)时读锚点跳过它、`restoreTurnAnchor` 判为失败,调用方放弃这次补偿并重新起锚——不回跳,也不按别的块硬对齐。 - 验证(2026-10-02 追加,稳定块身份):锚点纯函数用例改为按块身份构造(`buildList` 传 `[回合 key, 块身份]`),新增「回合收口后块序号整体后移,块身份仍指向同一块且还原成功」「收起的 `
` 里的块没有布局盒:读锚点跳过、还原返回 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` 原来把重试按钮渲染在 `

` 里面,而 `role="alert"` 隐含 `aria-live="assertive"` + `aria-atomic="true"`,交互控件会被卷进整段断言性播报、读屏也不一定把它当可聚焦按钮。修正:容器改为普通 `

`,`role="alert"` 只包住「加载更早对话失败」文案本身,重试按钮是 live region 之外的兄弟。 +- 验证(2026-10-02 追加,错误行结构):`DirectProjectConversation.test.tsx` 新增一例断言 `role="alert"` 节点不包含重试按钮、且文案仍在 alert 内并仍可点击;修正前红。 ## 2026-10-01 Web、后台与 AGC 一键联调