短历史一屏以内时收起悬空的「回到底部」胶囊

- useConversationScroll 新增 syncBottomFromLayout:按真实几何复核 isNearBottom,为真即恢复跟随、收起胶囊并清掉「有新回复」
- 布局补偿、turns 内容变化与 ResizeObserver/MutationObserver 回调都先走一次复核,内容塌回一屏以内不再依赖滚动事件
- 补回归测试:列表内容塌回一屏以内并触发布局变化,没有任何滚动事件也应收起胶囊
- ADR 第 3 节记录贴底判定不能只认 onScroll
This commit is contained in:
2026-10-07 10:49:56 +08:00
parent 89d0f507e6
commit c95ea8af85
3 changed files with 66 additions and 3 deletions
@@ -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(
<Harness
turns={[turn('t1')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
const capsule = () => view.getByTestId('capsule').textContent;
metrics.scrollTop = 0;
fireEvent.scroll(view.getByTestId('list'));
expect(capsule()).toBe('回到底部');
// 回合收口把过程折进收起的 `<details>`:内容塌回一屏以内,列表不会再发任何滚动事件。
// 只认 onScroll 里那次判定的 atBottom 会把胶囊永远挂在一个滚不动的列表上。
metrics.scrollHeight = 400;
FakeResizeObserver.triggerLayoutChange();
expect(capsule()).toBe('');
});
});
describe('加载行', () => {
@@ -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`,但内容**变短**到一屏以内时不会有滚动事件:回合收口把
* 过程折进收起的 `<details>`、历史加载行卸载,都会让 `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({
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
}, []);
}, [syncBottomFromLayout]);
// 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」
// 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容,
@@ -214,8 +239,11 @@ export function useConversationScroll({
scrollListToBottom(listRef.current, 'auto');
return;
}
// 回合收口会把过程折进收起的 `<details>`,内容可能就此塌回一屏以内:先按真实几何复核,
// 已经滚无可滚就不再点亮「有新回复」(那时列表里根本没有可去的底部)。
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]);
// 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
// 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
@@ -40,6 +40,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
- 贴底判定不能只认 `onScroll`:内容**变短**到一屏以内时不会再有任何滚动事件(回合收口把过程折进收起的 `<details>`、历史加载行卸载),`atBottom` 会停在离开底部时的假值,胶囊就永远挂在一个滚不动的列表上(短历史一屏显示完却还悬着「回到底部」)。因此每次布局变化(`ResizeObserver`)、子元素增删(`MutationObserver`)与内容变化后都按真实几何复核一次 `isNearBottom`,复核为真即收起胶囊并清掉「有新回复」。
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。