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 4a069da20..b64824c24 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
@@ -16,15 +16,53 @@ const PATCHED_GEOMETRY = [
'scrollHeight',
'clientHeight',
'getBoundingClientRect',
+ 'scrollTo',
] as const;
+/**
+ * 假 ResizeObserver:记录回调,用例自己决定何时模拟一次布局变化。
+ *
+ * jsdom 没有 ResizeObserver,也不做布局,滚动补偿的唯一入口只能靠它驱动。
+ */
+class FakeResizeObserver {
+ private static callbacks: Array<() => void> = [];
+ private readonly callback: () => void;
+
+ constructor(callback: () => void) {
+ this.callback = callback;
+ FakeResizeObserver.callbacks.push(callback);
+ }
+
+ observe(): void {}
+
+ unobserve(): void {}
+
+ disconnect(): void {
+ FakeResizeObserver.callbacks = FakeResizeObserver.callbacks.filter(
+ (item) => item !== this.callback,
+ );
+ }
+
+ static reset(): void {
+ FakeResizeObserver.callbacks = [];
+ }
+
+ /** 模拟一次「列表子元素高度变了」:展开、图片撑高、历史前插都走这条路。 */
+ static triggerLayoutChange(): void {
+ act(() => {
+ for (const callback of [...FakeResizeObserver.callbacks]) callback();
+ });
+ }
+}
+
/**
* jsdom 不做布局:几何量全部挂到 `HTMLDivElement.prototype` 上,用例自己给数字。
*
* 挂在原型而不是实例上,是因为列表元素由 React 在渲染时创建,挂载期读几何的效果必须
* 一开始就能读到这些值。`afterEach` 删掉这些自有属性即可落回 jsdom 的原实现。
*/
-function stubListMetrics(metrics: ListMetrics): void {
+function stubListMetrics(metrics: ListMetrics): { writes: number } {
+ const counter = { writes: 0 };
Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', {
configurable: true,
get: () => metrics.scrollHeight,
@@ -37,6 +75,7 @@ function stubListMetrics(metrics: ListMetrics): void {
configurable: true,
get: () => metrics.scrollTop,
set: (value: number) => {
+ counter.writes += 1;
metrics.scrollTop = value;
},
});
@@ -55,6 +94,26 @@ function stubListMetrics(metrics: ListMetrics): void {
toJSON: () => ({}),
}) as DOMRect,
});
+ return counter;
+}
+
+/**
+ * 平滑滚动在 jsdom 里没有实现:只记录目标位置。
+ *
+ * 真实浏览器里写一次 `scrollTop` 就会取消动画,而 jsdom 不会——所以「动画被打断」这件事
+ * 只能靠「有没有人写 `scrollTop`」来断言,不能靠位置收敛来断言。
+ */
+function stubSmoothScroll(): { targets: number[] } {
+ const calls = { targets: [] as number[] };
+ Object.defineProperty(HTMLDivElement.prototype, 'scrollTo', {
+ configurable: true,
+ value: (options?: ScrollToOptions | number) => {
+ calls.targets.push(
+ typeof options === 'number' ? options : (options?.top ?? 0),
+ );
+ },
+ });
+ return calls;
}
function Harness({
@@ -82,11 +141,19 @@ function Harness({
});
return (
<>
+ {/* 一个回合块:前插锚点与「往哪儿补偿」都要有真实可定位的元素。 */}
+
{scroll.showScrollToBottom ? scroll.scrollToBottomLabel : ''}
@@ -112,6 +179,8 @@ const turn = (
afterEach(() => {
cleanup();
+ FakeResizeObserver.reset();
+ vi.unstubAllGlobals();
for (const key of PATCHED_GEOMETRY) {
Reflect.deleteProperty(HTMLDivElement.prototype, key);
}
@@ -316,3 +385,78 @@ describe('加载行', () => {
}
});
});
+
+describe('点「回到底部」后的程序化滚动', () => {
+ function renderLongList() {
+ const metrics: ListMetrics = {
+ scrollTop: 0,
+ scrollHeight: 1200,
+ clientHeight: 400,
+ };
+ const counter = stubListMetrics(metrics);
+ const smooth = stubSmoothScroll();
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver);
+ const view = render(
+ undefined}
+ />,
+ );
+ const list = view.getByTestId('list');
+ // 用户先往上滚,离开底部:胶囊出现,之后才点它。
+ metrics.scrollTop = 0;
+ fireEvent.scroll(list);
+ return { metrics, counter, smooth, view, list };
+ }
+
+ it('动画途中收到滚动事件不算用户意图:胶囊不闪回', () => {
+ const { metrics, smooth, view } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+ expect(smooth.targets).toEqual([1200]);
+
+ // 平滑动画刚走了一帧、还没到贴底阈值。
+ metrics.scrollTop = 420;
+ fireEvent.scroll(view.getByTestId('list'));
+
+ expect(view.getByTestId('capsule').textContent).toBe('');
+ });
+
+ it('动画途中不写 scrollTop:写一次就把动画取消在半路', () => {
+ const { metrics, counter, view, list } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+ const writesBefore = counter.writes;
+
+ metrics.scrollTop = 420;
+ fireEvent.scroll(list);
+ FakeResizeObserver.triggerLayoutChange();
+
+ expect(counter.writes).toBe(writesBefore);
+ });
+
+ it('动画落到位后交还控制权:再往上滚照样显示胶囊', () => {
+ const { metrics, view, list } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+
+ metrics.scrollTop = 1200 - 400;
+ fireEvent.scroll(list);
+ expect(view.getByTestId('capsule').textContent).toBe('');
+
+ metrics.scrollTop = 400;
+ fireEvent.scroll(list);
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ });
+
+ it('用户中途用滚轮打断:立刻交还控制权,后续滚动照常判定', () => {
+ const { metrics, view, list } = renderLongList();
+ fireEvent.click(view.getByTestId('to-bottom'));
+
+ metrics.scrollTop = 420;
+ fireEvent.wheel(list);
+ fireEvent.scroll(list);
+
+ expect(view.getByTestId('capsule').textContent).toBe('回到底部');
+ });
+});
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 cb3e2e7e8..a30acc1d2 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
@@ -86,6 +86,15 @@ export function useConversationScroll({
const listRef = useRef(null);
/** 跟随时任何高度变化都贴底;用户离开底部(或点胶囊)即翻转。 */
const followLatestRef = useRef(true);
+ /**
+ * 程序化平滑滚动在飞吗。
+ *
+ * 为真时滚动位置归这次动画所有:滚动事件不翻转跟随、布局补偿不写 `scrollTop`。
+ * 真实浏览器里只要写一次 `scrollTop`,正在跑的平滑动画就被取消——补偿与锚点还原都是写
+ * `scrollTop`,所以「点回到底部只下去一屏就停住」的直接原因就在这里。
+ * 滚到贴底阈值、或用户用滚轮 / 触摸 / 键盘接手时交还。
+ */
+ const programmaticScrollRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [hasNewReply, setHasNewReply] = useState(false);
/** 前插时要保住的顶部可见回合;每次滚动与补偿后刷新。 */
@@ -130,6 +139,9 @@ export function useConversationScroll({
const list = listRef.current;
if (!list) return;
+ // 程序化滚动在飞:滚动位置归它所有,这里写一次就会把动画取消在半路。
+ if (programmaticScrollRef.current) return;
+
if (followLatestRef.current) {
scrollListToBottom(list, 'auto');
return;
@@ -194,11 +206,30 @@ export function useConversationScroll({
};
}, [compensateLayout]);
+ // 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
+ // 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list) return undefined;
+ const release = () => {
+ programmaticScrollRef.current = false;
+ };
+ list.addEventListener('wheel', release, { passive: true });
+ list.addEventListener('touchstart', release, { passive: true });
+ list.addEventListener('keydown', release);
+ return () => {
+ list.removeEventListener('wheel', release);
+ list.removeEventListener('touchstart', release);
+ list.removeEventListener('keydown', release);
+ };
+ }, []);
+
// 新一回合开始(假 → 真)强制恢复跟随:用户上一轮跑完时停在中途,这一轮不该再让他手动滚。
useEffect(() => {
const started = turnInFlight && !turnInFlightRef.current;
turnInFlightRef.current = turnInFlight;
if (!started) return;
+ programmaticScrollRef.current = false;
followLatestRef.current = true;
setAtBottom(true);
setHasNewReply(false);
@@ -227,6 +258,11 @@ export function useConversationScroll({
const list = event.currentTarget;
const metrics = readConversationListMetrics(list);
const nearBottom = isNearBottom(metrics);
+ if (programmaticScrollRef.current) {
+ // 平滑滚动期间的中间帧不是用户意图:这里翻转跟随或换锚点,都会让补偿立刻把动画取消。
+ if (!nearBottom) return;
+ programmaticScrollRef.current = false;
+ }
followLatestRef.current = nearBottom;
setAtBottom(nearBottom);
if (nearBottom) setHasNewReply(false);
@@ -254,7 +290,12 @@ export function useConversationScroll({
followLatestRef.current = true;
setAtBottom(true);
setHasNewReply(false);
- scrollListToBottom(listRef.current, 'smooth');
+ const list = listRef.current;
+ // 只有真的还要走一段距离才接管滚动位置:已经贴底的列表不会再有滚动事件,挂上标记就没人交还。
+ if (list && !isNearBottom(readConversationListMetrics(list))) {
+ programmaticScrollRef.current = true;
+ }
+ scrollListToBottom(list, 'smooth');
}, []);
return {