From c95ea8af854d6c423ce035a187ca965b318bfb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 10:49:56 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9F=AD=E5=8E=86=E5=8F=B2=E4=B8=80=E5=B1=8F?= =?UTF-8?q?=E4=BB=A5=E5=86=85=E6=97=B6=E6=94=B6=E8=B5=B7=E6=82=AC=E7=A9=BA?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E5=9B=9E=E5=88=B0=E5=BA=95=E9=83=A8=E3=80=8D?= =?UTF-8?q?=E8=83=B6=E5=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useConversationScroll 新增 syncBottomFromLayout:按真实几何复核 isNearBottom,为真即恢复跟随、收起胶囊并清掉「有新回复」 - 布局补偿、turns 内容变化与 ResizeObserver/MutationObserver 回调都先走一次复核,内容塌回一屏以内不再依赖滚动事件 - 补回归测试:列表内容塌回一屏以内并触发布局变化,没有任何滚动事件也应收起胶囊 - ADR 第 3 节记录贴底判定不能只认 onScroll --- .../useConversationScroll.test.tsx | 31 ++++++++++++++++ .../useConversationScroll.ts | 37 +++++++++++++++++-- ...tProject对话滚动与历史自动加载-2026-10-02.md | 1 + 3 files changed, 66 insertions(+), 3 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 51e8a502b..8599be37f 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 @@ -356,6 +356,37 @@ describe('回到底部胶囊', () => { ); expect(capsule()).toBe('回到底部'); }); + + it('内容塌回一屏以内:没有滚动事件也收起胶囊', () => { + const metrics: ListMetrics = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + stubListMetrics(metrics); + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + const view = render( + undefined} + />, + ); + const capsule = () => view.getByTestId('capsule').textContent; + + metrics.scrollTop = 0; + fireEvent.scroll(view.getByTestId('list')); + expect(capsule()).toBe('回到底部'); + + // 回合收口把过程折进收起的 `
`:内容塌回一屏以内,列表不会再发任何滚动事件。 + // 只认 onScroll 里那次判定的 atBottom 会把胶囊永远挂在一个滚不动的列表上。 + metrics.scrollHeight = 400; + FakeResizeObserver.triggerLayoutChange(); + + expect(capsule()).toBe(''); + }); }); describe('加载行', () => { 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 ca00bc8fc..9f12d8a91 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 @@ -17,6 +17,7 @@ import { } from './conversationScrollAnchor'; import { HISTORY_LOADING_INDICATOR_DELAY_MS, + HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS, isNearBottom, readConversationListMetrics, scrollToBottomLabel, @@ -127,6 +128,7 @@ export function useConversationScroll({ const showHistoryLoading = useDelayedFlag( historyLoading, HISTORY_LOADING_INDICATOR_DELAY_MS, + HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS, ); /** @@ -141,6 +143,26 @@ export function useConversationScroll({ loadEarlierRef.current(); }, []); + /** + * 按真实几何复核「在底部」,返回复核结果。 + * + * `atBottom` 的唯一常规来源是 `onScroll`,但内容**变短**到一屏以内时不会有滚动事件:回合收口把 + * 过程折进收起的 `
`、历史加载行卸载,都会让 `scrollHeight` 掉回 `clientHeight` 以下, + * 之后列表根本滚不动,可 `atBottom` 还停在离开底部时的假值——「回到底部」胶囊就永远挂在一个没有 + * 可滚余量的列表上。布局变化与内容变化都是复核点。 + * + * 复核为真时同步恢复跟随并清掉「有新回复」:都已经滚无可滚了,留着未读提示只会是指向空处的入口。 + */ + const syncBottomFromLayout = useCallback((): boolean => { + const list = listRef.current; + if (!list) return followLatestRef.current; + if (!isNearBottom(readConversationListMetrics(list))) return false; + followLatestRef.current = true; + setAtBottom(true); + setHasNewReply(false); + return true; + }, []); + /** * 布局变化后的补偿:跟随时贴底;否则优先冻结刚展开的折叠头,再退到前插锚点。 * @@ -151,6 +173,9 @@ export function useConversationScroll({ const list = listRef.current; if (!list) return; + // 先按真实几何复核贴底:内容塌回一屏以内时不会再有任何滚动事件来翻转 `atBottom`。 + syncBottomFromLayout(); + // 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。 // 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会 // 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿 @@ -187,7 +212,7 @@ export function useConversationScroll({ // 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `
`、没有布局盒算不出偏移): // 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。 preserveAnchorRef.current = readTopVisibleTurnAnchor(list); - }, []); + }, [syncBottomFromLayout]); // 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」 // 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容, @@ -214,8 +239,11 @@ export function useConversationScroll({ scrollListToBottom(listRef.current, 'auto'); return; } + // 回合收口会把过程折进收起的 `
`,内容可能就此塌回一屏以内:先按真实几何复核, + // 已经滚无可滚就不再点亮「有新回复」(那时列表里根本没有可去的底部)。 + if (syncBottomFromLayout()) return; if (gainedTerminalContent) setHasNewReply(true); - }, [turns]); + }, [syncBottomFromLayout, turns]); // 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。 // 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。 @@ -232,6 +260,9 @@ export function useConversationScroll({ for (const child of Array.from(list.children)) { observer.observe(child); } + // 子元素增删本身就可能让内容长度跨过视口(历史加载行挂卸、回合收口折叠),而这不会命中任何 + // 被观察子元素的高度变化:这里按真实几何补一次贴底复核,避免胶囊赖在滚不动的列表上。 + syncBottomFromLayout(); }; subscribe(); const mutations = new MutationObserver(subscribe); @@ -241,7 +272,7 @@ export function useConversationScroll({ mutations.disconnect(); observer?.disconnect(); }; - }, [compensateLayout]); + }, [compensateLayout, syncBottomFromLayout]); // 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。 // 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。 diff --git a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md index 22fb1dd55..dc543d888 100644 --- a/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md +++ b/docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md @@ -40,6 +40,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme - 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。 - 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。 +- 贴底判定不能只认 `onScroll`:内容**变短**到一屏以内时不会再有任何滚动事件(回合收口把过程折进收起的 `
`、历史加载行卸载),`atBottom` 会停在离开底部时的假值,胶囊就永远挂在一个滚不动的列表上(短历史一屏显示完却还悬着「回到底部」)。因此每次布局变化(`ResizeObserver`)、子元素增删(`MutationObserver`)与内容变化后都按真实几何复核一次 `isNearBottom`,复核为真即收起胶囊并清掉「有新回复」。 - 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。 - 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。 - 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。