fix(chat): pin every newly sent message to the top, not just typed ones

Two independent defects were splitting the behaviour in half, which is why
it looked intermittent.

Whether to pin was something each send site had to declare by raising a
flag, and that flag was raised in exactly one place: the composer's own
callback. Seven of the eleven entrances that put a new user message on
screen never went through it — question-form answers, the home page's first
send, annotations, anything the queue released, resume, and image retry.
Pinning now follows from the transcript itself: the trailing user message
changed identity, so a new turn is on screen, regardless of which button
produced it. New entrances are covered by construction, which matters
because no one has ever remembered to add the line.

The other half is the reason it varied run to run. The pin scrolled
smoothly, and nothing distinguishes our own animation from a user dragging
the scrollbar — position is all either one reports. Mid-animation frames
sit far outside the release tolerance, so the first one dropped the pin and
froze the tail spacer; the final frame lands exactly at the bottom while
the reply is still empty, which re-arms stick-to-bottom and drags the
message back off the top. A reply that starts quickly grows the log past
that point and the pin survives; a slow one does not. Same code, opposite
outcome.

Self-initiated scrolls are already required to be instant — the
question-form anchor was moved off smooth for this exact reason — and this
was the last one still animating.

Visible change: the pin now lands on the frame instead of gliding, losing
roughly 300ms of animation. That animation was the defect.

Also updates the feedback-panel assertion left behind by c121d81b17, which
changed that scroll to nearest/auto without carrying its test along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
This commit is contained in:
lefarcen
2026-09-02 16:11:33 +08:00
parent 6c9d6b6a04
commit d287845f0e
5 changed files with 970 additions and 51 deletions
+77 -49
View File
@@ -6,6 +6,13 @@ import {
type FollowIntent,
type ScrollSample,
} from '../runtime/chat/stick-to-bottom';
import {
ANCHOR_TOP_PADDING,
anchorReleasedByScroll,
anchorScrollTop,
anchorSpacerHeight,
isNewTailUserTurn,
} from '../runtime/chat/anchor-to-top';
import { appendQuoteOutcome, type ChatQuote } from '../runtime/chat/quote-selection';
import {
captureElementScrollAnchor,
@@ -910,9 +917,6 @@ interface QueuedSendUpdate {
meta?: ChatSendMeta;
}
// Gap left above the anchored user message when it is pinned to the top.
const ANCHOR_TOP_PADDING = 12;
/**
* Fold an OD Next logical task into ONE conversation turn.
*
@@ -1426,23 +1430,28 @@ export function ChatPane({
};
}, [refreshInlineAmrLoginStatus]);
// "Anchor the just-sent turn to the top" (ChatGPT-style). On send we pin
// the user's message to the top of the viewport and let the reply stream
// below it instead of following the bottom. `pending` is armed by the
// composer's onSend; the messages effect promotes it to `active` once the
// new user turn actually renders. A dynamic tail spacer reserves just
// enough real, scrollable blank space below the turn so the message can
// reach the top even when the reply is short. The spacer is only resized
// while the message sits at its pinned position — once the user scrolls
// below it, the reserved blank stays put (no collapse, no jump).
const anchorPendingRef = useRef(false);
/*
* "Anchor the just-sent turn to the top" (ChatGPT-style):
* ,,
* ,();
* ,,
*
* **,**(`isNewTailUserTurn`)
* ( `pending` ),
* question-form
* ,,
*
*
* `undefined` = ( / ),:
*
*/
const settledTailUserIdRef = useRef<string | null | undefined>(undefined);
const anchorActiveRef = useRef(false);
const tailSpacerRef = useRef<HTMLDivElement | null>(null);
const chatRailHighlightTimerRef = useRef<ReturnType<typeof setTimeout>>();
const [chatRailHighlightedMessageId, setChatRailHighlightedMessageId] =
useState<string | null>(null);
const prevStreamingRef = useRef(streaming);
const prevLastUserIdRef = useRef<string | undefined>(undefined);
// AssistantMessage's interaction callbacks are re-created per render and
// excluded from its memo comparison (so streaming doesn't re-render every
// message). Route them through this ref so a memoized message still calls the
@@ -2243,9 +2252,8 @@ export function ChatPane({
useEffect(() => {
didInitialScrollRef.current = false;
anchorPendingRef.current = false;
anchorActiveRef.current = false;
prevLastUserIdRef.current = undefined;
settledTailUserIdRef.current = undefined;
resetTailSpacer();
// A new conversation should land at the bottom (its own initial
// scroll), not inherit the previous conversation's saved position —
@@ -2254,7 +2262,7 @@ export function ChatPane({
savedChatScrollRef.current = null;
scrolledToFormRef.current = new Set();
anchorActiveRef.current = false;
anchorPendingRef.current = false;
settledTailUserIdRef.current = undefined;
resetTailSpacer();
/*
* ****的阅读状态:在长会话里滚上去挣脱过,
@@ -2427,13 +2435,18 @@ export function ChatPane({
// threshold) so a deliberate ~90px scroll-up isn't snapped back the
// next time content streams in. Issue #983.
// A brand-new user turn from a local send: switch to "anchor to top"
// mode and smooth-scroll their message to the top of the viewport.
/*
* ,
*
* ( id ),:
* question-form
* , `isNewTailUserTurn`
*/
const lastUser = [...displayMessages].reverse().find((m) => m.role === 'user');
const prevUserId = prevLastUserIdRef.current;
prevLastUserIdRef.current = lastUser?.id;
if (anchorPendingRef.current && lastUser && lastUser.id !== prevUserId) {
anchorPendingRef.current = false;
const tailUserId = lastUser?.id ?? null;
const settledTailUserId = settledTailUserIdRef.current;
settledTailUserIdRef.current = tailUserId;
if (isNewTailUserTurn(settledTailUserId, tailUserId)) {
resetTailSpacer();
anchorActiveRef.current = true;
/*
@@ -2576,8 +2589,11 @@ export function ChatPane({
if (anchorActiveRef.current) {
const pinnedTop = lastUserMsgTopInContent(target);
if (
pinnedTop !== null &&
Math.abs(target.scrollTop - (pinnedTop - ANCHOR_TOP_PADDING)) > 40
pinnedTop !== null
&& anchorReleasedByScroll({
scrollTop: target.scrollTop,
messageTopInContent: pinnedTop,
})
) {
anchorActiveRef.current = false;
}
@@ -3114,23 +3130,43 @@ export function ChatPane({
if (!el || !spacer) return;
const msgTopInContent = lastUserMsgTopInContent(el);
if (msgTopInContent === null) return;
const spacerH = spacer.offsetHeight;
const contentBelow = el.scrollHeight - spacerH - msgTopInContent;
const needed = Math.max(0, el.clientHeight - contentBelow - ANCHOR_TOP_PADDING);
spacer.style.height = `${needed}px`;
spacer.style.height = `${anchorSpacerHeight({
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
spacerHeight: spacer.offsetHeight,
messageTopInContent: msgTopInContent,
})}px`;
}
// Smooth-scroll the anchored message to the top. Called ONCE per turn (on
// send). The message then stays at the top on its own as the reply streams
// below it, so we never re-scroll — re-scrolling each chunk is what caused
// the scroll-down fight and the settle jitter.
/**
* ****()
* ,,;
* +
*
* ## ****, `writeLogScrollTop`
*
* ,
* ( `onScroll` `anchorReleasedByScroll`, `stick-to-bottom.ts`
* )`behavior:'smooth'` ****:
*
* · ,
* ,;
* · ( == ),
* , +
* ,,
*
*
* 瞬时写入没有这个窗口:位置和基线在同一拍里落定(`writeLogScrollTop`
* 线), scroll ,
* , `stick-to-bottom.ts` question-form
* (`scrollQuestionFormToTop`),
*/
function scrollAnchorToTop() {
const el = logRef.current;
if (!el) return;
const msgTopInContent = lastUserMsgTopInContent(el);
if (msgTopInContent === null) return;
const target = Math.max(0, msgTopInContent - ANCHOR_TOP_PADDING);
el.scrollTo({ top: target, behavior: 'smooth' });
writeLogScrollTop(el, anchorScrollTop(msgTopInContent));
}
function jumpToBottom() {
@@ -3278,22 +3314,14 @@ export function ChatPane({
return;
}
armFollow();
// Arm "anchor to top": the messages effect promotes this once
// the new user turn renders, pinning it to the top of the view.
// Clear any stale reserve from the previous turn first so a resend
// doesn't strand the new turn below a leftover gap (release #3653).
// Clear any stale reserve from the previous turn before the new one
// renders, so a resend doesn't flash the new turn below a leftover gap
// (release #3653). 「要不要钉顶」不在这里表态 —— 那是消息流水的结构
// 说了算(见 `isNewTailUserTurn`),不然每加一个发送入口就要在这里补一行,
// 而实际上从来没人补过。
anchorActiveRef.current = false;
resetTailSpacer();
anchorPendingRef.current = true;
const outcome = onSend(prompt, attachments, commentAttachments, meta);
if (outcome instanceof Promise) {
return outcome.then((result) => {
if (result === 'restore-draft') anchorPendingRef.current = false;
return result;
});
}
if (outcome === 'restore-draft') anchorPendingRef.current = false;
return outcome;
return onSend(prompt, attachments, commentAttachments, meta);
}}
onStop={onStop}
onOpenSettings={onOpenSettings}
+121
View File
@@ -0,0 +1,121 @@
/**
* 「刚发出去的这一轮,钉到聊天区顶端」的**纯判据**。
*
* 这块几何原来整段写在 `ChatPane` 里,读不出、也测不到:jsdom 没有布局,
* `scrollHeight` / `clientHeight` / `getBoundingClientRect()` 默认全是 0,
* 于是「断言滚到顶」的用例在**没有实现**的时候也是绿的(期望值和实际值都是 0)。
* 判据搬到这里之后,每一条都可以喂显式的几何数字,红绿都是真的。
*
* ## 这套机制由三件事组成
*
* 1. **尾部占位块**(`anchorSpacerHeight`)——在回复下面撑出一块**真实可滚动**的
* 空白,好让这条用户消息物理上够得着视口顶端。短回复(甚至还没有回复)时,
* 没有这块空白就根本滚不上去 —— 用户会觉得「置顶没生效」,而其实是滚不动。
* 2. **落点**(`anchorScrollTop`)——占位块按 (1) 定过尺寸之后,消息顶到上沿
* 对应的 `scrollTop`。这两个数是**同一副几何的两面**:占位块正好撑到
* 「落点 == 能滚到的最大位置」,所以钉住之后再怎么长内容,视图都不会被夹取推走。
* 3. **松手判据**(`anchorReleasedByScroll`)——用户自己滚开了多远才算「不钉了」。
*
* ## 【不变量】钉住这一跳必须是**瞬时**的
*
* (3) 分不出「谁发起的滚动」——平台也不打算让它分得出(见 `stick-to-bottom.ts`
* 里那一整段)。所以 `behavior:'smooth'` 会让这套机制**自己把自己判掉**:
* 动画中间的每一帧离落点都远远超过 `ANCHOR_RELEASE_SLACK_PX`,第一帧就把钉住
* 状态清掉了,之后占位块再也不收缩,而贴底跟随可能在动画最后一帧被重新挂上,
* 把用户拽到底 —— 这正是「有时候置顶了有时候没有」的来源。
* 调用方必须用 `behavior:'auto'` 并走自己那个「写完就记基线」的写入口。
*/
/** 钉住的消息上边留的那点空隙。 */
export const ANCHOR_TOP_PADDING = 12;
/**
* 离钉住位置超过这么多像素,才算「用户自己滚开了」。
*
* 不能取 0:占位块每一帧都在收缩,浏览器夹取会带来亚像素级的漂移。
*/
export const ANCHOR_RELEASE_SLACK_PX = 40;
export interface AnchorGeometry {
/** 视口高。 */
clientHeight: number;
/** 可滚内容总高,**含**尾部占位块 —— 就是 `el.scrollHeight` 的读数。 */
scrollHeight: number;
/** 尾部占位块此刻的高度。 */
spacerHeight: number;
/** 被钉住那条用户消息距内容顶端的偏移(与当前 `scrollTop` 无关)。 */
messageTopInContent: number;
}
/** 这条消息下面还有多少**真内容**(占位块不算)。 */
function contentBelowAnchor(geometry: AnchorGeometry): number {
return Math.max(
0,
geometry.scrollHeight - geometry.spacerHeight - geometry.messageTopInContent,
);
}
/**
* 尾部占位块要多高,这条消息才顶得到视口上沿。
*
* 回复越长,`needed` 越小,一路单调收缩到 0 —— 所以这是一次**纯缩小**的 resize,
* 在用户钉在顶端时改不了任何可见内容的位置,不会抖。
*/
export function anchorSpacerHeight(geometry: AnchorGeometry): number {
return Math.max(
0,
geometry.clientHeight - contentBelowAnchor(geometry) - ANCHOR_TOP_PADDING,
);
}
/** 钉住位置对应的 `scrollTop`。 */
export function anchorScrollTop(messageTopInContent: number): number {
return Math.max(0, messageTopInContent - ANCHOR_TOP_PADDING);
}
/**
* 占位块按 `anchorSpacerHeight` 定过尺寸之后,能滚到的最大位置。
*
* 它**恒等于** `anchorScrollTop` ——「刚好够钉到顶,一个像素都不多」正是占位块的定义。
* 单独导出是为了让这条恒等式可以被断言,而不是只写在注释里。
*/
export function maxScrollTopAfterAnchorSpacer(geometry: AnchorGeometry): number {
const below = contentBelowAnchor(geometry);
const total = geometry.messageTopInContent + below + anchorSpacerHeight(geometry);
return Math.max(0, total - geometry.clientHeight);
}
/** 这一次滚动是不是把用户带离了钉住位置。 */
export function anchorReleasedByScroll(input: {
scrollTop: number;
messageTopInContent: number;
}): boolean {
return (
Math.abs(input.scrollTop - anchorScrollTop(input.messageTopInContent))
> ANCHOR_RELEASE_SLACK_PX
);
}
/**
* 尾部这条用户消息是不是「刚刚新出现的一轮」——**该不该钉顶,只由这一条决定**。
*
* 老写法是每个发送入口各自举手(`anchorPendingRef.current = true`),而举手的
* 只有输入框那一个入口。首页发起、question-form 交答案、批注发起、队列排到、
* 失败后的「继续」、生图重试 …… 一条都不走输入框,于是它们全都钉不了顶。
* 「有时候有有时候没有」的另一半就是这个。
*
* 改成认**结构**:尾条用户消息的 id 换了 = 屏幕上多了一轮新的用户消息,和它是
* 从哪个按钮出来的无关。少一份状态,也就少一处「新入口忘了接」。
*
* `settledTailUserId === undefined` 表示这条会话还没落定过(初次装载 / 刚切会话)。
* 那一拍**不钉**:整篇转录一次性到齐,不是新发了一轮。空会话落定成 `null`,
* 所以它的第一条用户消息仍然算新的一轮(首页发起走的就是这一格)。
*/
export function isNewTailUserTurn(
settledTailUserId: string | null | undefined,
tailUserId: string | null,
): boolean {
if (settledTailUserId === undefined) return false;
if (tailUserId === null) return false;
return tailUserId !== settledTailUserId;
}
@@ -0,0 +1,619 @@
// @vitest-environment jsdom
/**
* 发出去的那一轮,必须钉在聊天区顶端 —— **每个入口都是,整轮都是**。
*
* ## 缺陷(用户原话:「现在这个行为有时候有有时候没有」)
*
* 两处,各占一半:
*
* 1. **入口没接。** 「该钉顶了」是每个发送入口自己举手的
* (`anchorPendingRef.current = true`),而举手的只有输入框那一个。
* question-form 交答案、首页发起、批注发起、队列排到、失败后的「继续」、
* 生图重试 …… 全都直接调宿主的 `handleSend`,一个都不举手,于是它们发出来的
* 那一轮走的是贴底跟随,消息在底部而不是顶端。
*
* 2. **钉住这一跳用了平滑滚动。** `scrollAnchorToTop()` 是
* `scrollTo({behavior:'smooth'})`,而「用户是不是自己滚开了」的判据只看位置
* (`ChatPane` 的 40px 容差 / `stick-to-bottom.ts` 的方向判据)—— 平台不提供
* 滚动来源,谁都分不出。于是动画自己的中间帧被判成「用户滚开了」,钉住状态
* 在第一帧就被清掉:占位块从此不再收缩,而动画最后一帧如果正好落在底部
* (回复还没开始吐字时**必然**如此,因为占位块就是照着「落点 == 底部」撑的),
* 贴底跟随还会被重新挂上,把用户一路拽到底。回复来得快慢决定它落在哪一边 ——
* 这就是「有时候有有时候没有」。
*
* 同一条不变量在这个仓库里已经写过两遍了:`stick-to-bottom.ts` 的
* 「自己发起的滚动一律瞬时」,以及 question-form 定位从 smooth 改成 auto 时
* 留下的那段注释。`scrollAnchorToTop` 是最后一处没改的。
*
* ## 这个夹具在模拟什么
*
* jsdom 没有布局,`scrollHeight` / `clientHeight` / `getBoundingClientRect()`
* 默认全是 0 —— 直接断言「滚到顶」的用例在**没有实现**时也是绿的。所以这里
* 把几何全部显式桩出来,并按 CSSOM-View「perform a scroll」补上 `scrollTo`
* 的两条分支:`'auto'` 同步落到终点,`'smooth'` 当场不动、之后一帧一帧地挪
* (终点在调用那一刻算死,内容再长也不跟着改)。建模的是平台契约,不是我们的实现。
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import {
ANCHOR_TOP_PADDING,
anchorScrollTop,
} from '../../src/runtime/chat/anchor-to-top';
import type { ChatMessage } from '../../src/types';
import { flushMounts, pressEnter, typeAndSettle } from '../helpers/lexical-composer';
type Geom = {
/** 真实内容高度,**不含**尾部占位块。 */
contentHeight: number;
clientHeight: number;
scrollTop: number;
/** 最后一条用户消息距内容顶端的偏移。 */
lastUserTopInContent: number;
};
const VIEWPORT = 600;
/** 一条用户消息的高度。 */
const USER_MSG_H = 80;
let geom: Geom;
let rafCallbacks: FrameRequestCallback[];
let resizeCallbacks: ResizeObserverCallback[];
let savedDescriptors: Record<
'scrollTop' | 'scrollHeight' | 'clientHeight' | 'offsetHeight',
PropertyDescriptor | undefined
>;
let originalGetBoundingClientRect: PropertyDescriptor | undefined;
let originalScrollTo: PropertyDescriptor | undefined;
let originalResizeObserver: typeof ResizeObserver | undefined;
/** 平滑滚动还没落地的那一段。 */
let pendingSmooth: { from: number; to: number } | null = null;
/** 每次 `scrollTo` 拿到的 behavior —— 用来钉「传下去的到底是哪一个」。 */
let scrollToBehaviors: Array<ScrollBehavior | undefined>;
function isChatLog(el: HTMLElement): boolean {
return typeof el?.classList?.contains === 'function' && el.classList.contains('chat-log');
}
function isTailSpacer(el: HTMLElement): boolean {
return (
typeof el?.classList?.contains === 'function'
&& el.classList.contains('chat-log-tail-spacer')
);
}
function inlineHeight(el: HTMLElement | null): number {
if (!el) return 0;
const parsed = Number.parseFloat(el.style.height);
return Number.isFinite(parsed) ? parsed : 0;
}
function tailSpacerHeight(): number {
return inlineHeight(document.querySelector<HTMLElement>('.chat-log-tail-spacer'));
}
function scrollHeightOf(): number {
return geom.contentHeight + tailSpacerHeight();
}
function maxScrollTop(): number {
return Math.max(0, scrollHeightOf() - geom.clientHeight);
}
function chatLog(): HTMLElement {
return screen.getByTestId('chat-log');
}
/** 钉住那条消息此刻的落点。 */
function anchoredScrollTop(): number {
return anchorScrollTop(geom.lastUserTopInContent);
}
beforeEach(() => {
geom = {
contentHeight: 4_000,
clientHeight: VIEWPORT,
scrollTop: 0,
lastUserTopInContent: 3_800,
};
rafCallbacks = [];
resizeCallbacks = [];
pendingSmooth = null;
scrollToBehaviors = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
originalResizeObserver = globalThis.ResizeObserver;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: MockResizeObserver,
});
savedDescriptors = {
scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
scrollHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'),
clientHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'),
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'),
};
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.scrollTop : 0;
},
set(this: HTMLElement, v: number) {
if (!isChatLog(this)) return;
geom.scrollTop = Math.min(Math.max(0, v), maxScrollTop());
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? scrollHeightOf() : 0;
},
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get(this: HTMLElement) {
return isChatLog(this) ? geom.clientHeight : 0;
},
});
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get(this: HTMLElement) {
return isTailSpacer(this) ? inlineHeight(this) : 0;
},
});
/*
* `.chat-log` 自己保持全零矩形,于是「消息上边在内容里的偏移」= scrollTop +
* 矩形 top。最后一条用户消息按 `lastUserTopInContent` 说话;更早的那些排在它
* 上面(读的只有最后一条,这里只是别让它们撒谎)。
*/
originalGetBoundingClientRect = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'getBoundingClientRect',
);
const zeroRect = () => ({
top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
});
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
writable: true,
value(this: HTMLElement) {
if (
typeof this.classList?.contains === 'function'
&& this.classList.contains('msg')
&& this.classList.contains('user')
) {
const all = Array.from(document.querySelectorAll('.msg.user'));
const index = all.indexOf(this);
const isLast = index === all.length - 1;
const topInContent = isLast
? geom.lastUserTopInContent
: geom.lastUserTopInContent - (all.length - 1 - index) * 200;
const top = topInContent - geom.scrollTop;
return {
...zeroRect(),
top,
bottom: top + USER_MSG_H,
height: USER_MSG_H,
y: top,
} as DOMRect;
}
return zeroRect() as DOMRect;
},
});
/*
* CSSOM-View「perform a scroll」的两条分支。⚠ 这里**不能**把 smooth 折叠成
* 瞬时 —— 折叠掉的正是这条缺陷本身。
*/
originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo');
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
writable: true,
value(this: HTMLElement, arg?: ScrollToOptions | number) {
if (!isChatLog(this)) return;
const options = typeof arg === 'object' && arg !== null ? arg : { top: arg as number };
scrollToBehaviors.push(options.behavior);
const to = Math.min(Math.max(0, options.top ?? geom.scrollTop), maxScrollTop());
// 位置没变就不是一次滚动:浏览器不为它跑动画,也不发 scroll(csswg-drafts #8218)。
if (to === geom.scrollTop) return;
if (options.behavior === 'smooth') {
pendingSmooth = { from: geom.scrollTop, to };
return;
}
geom.scrollTop = to;
fireEvent.scroll(this);
},
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
rafCallbacks = [];
resizeCallbacks = [];
if (originalResizeObserver) {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: originalResizeObserver,
});
}
if (originalScrollTo) {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo);
} else {
delete (HTMLElement.prototype as unknown as Record<string, unknown>).scrollTo;
}
if (originalGetBoundingClientRect) {
Object.defineProperty(
HTMLElement.prototype, 'getBoundingClientRect', originalGetBoundingClientRect,
);
}
for (const key of ['scrollTop', 'scrollHeight', 'clientHeight', 'offsetHeight'] as const) {
const original = savedDescriptors[key];
if (original) {
Object.defineProperty(HTMLElement.prototype, key, original);
} else {
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
}
}
});
async function flushFrames() {
await act(async () => {
for (let round = 0; round < 6; round += 1) {
const callbacks = rafCallbacks.splice(0);
if (callbacks.length === 0) break;
callbacks.forEach((callback) => callback(performance.now()));
await Promise.resolve();
}
});
}
/** 内容长高之后 ResizeObserver 到达 —— 生产里「变了要去算」的真实通路。 */
async function triggerResize() {
await act(async () => {
[...resizeCallbacks].forEach((callback) => callback([], {} as ResizeObserver));
await Promise.resolve();
});
await flushFrames();
}
/**
* 平滑动画往前走几帧,每一帧发一个 scroll —— 浏览器就是这么做的。
*
* 瞬时滚动没有动画可走(位置在调用那一拍就落定了),这里**不报错**是有意的:
* 用例钉的是「消息还在不在顶端」,不是「用了哪种滚法」。哪天有人把平滑改回来,
* 立刻又有帧可走,红的还是同一条。
*/
async function advanceSmoothScroll(frames = 4) {
const anim = pendingSmooth;
if (!anim) return;
const log = chatLog();
for (let i = 1; i <= frames; i += 1) {
await act(async () => {
geom.scrollTop = Math.round(anim.from + (anim.to - anim.from) * (i / frames));
fireEvent.scroll(log);
await Promise.resolve();
});
}
pendingSmooth = null;
}
function history(): ChatMessage[] {
const messages: ChatMessage[] = [];
for (let i = 0; i < 8; i += 1) {
messages.push({
id: `u${i}`, role: 'user', content: `request ${i}`,
createdAt: 1_700_000_000_000 + i * 2,
});
messages.push({
id: `a${i}`, role: 'assistant', content: `reply ${i}`,
createdAt: 1_700_000_000_000 + i * 2 + 1,
});
}
return messages;
}
function withNewTurn(replyText: string | null): ChatMessage[] {
const messages = history();
messages.push({
id: 'u-new', role: 'user', content: 'the turn we just sent',
createdAt: 1_700_000_001_000,
});
if (replyText !== null) {
messages.push({
id: 'a-new', role: 'assistant', content: replyText,
createdAt: 1_700_000_001_001, runStatus: 'running',
});
}
return messages;
}
function chatPaneEl(
messages: ChatMessage[],
streaming: boolean,
onSend: (prompt: string) => void = () => {},
) {
return (
<ChatPane
messages={messages}
streaming={streaming}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={(prompt) => { onSend(prompt); }}
onStop={() => {}}
conversations={[]}
activeConversationId="conv-1"
onSelectConversation={() => {}}
onDeleteConversation={() => {}}
/>
);
}
/** 新一轮到达:内容长高、最后一条用户消息换成新的那条。 */
function arriveNewUserTurn() {
geom.contentHeight = 4_000 + USER_MSG_H;
geom.lastUserTopInContent = 4_000;
}
describe('夹具自检:几何真的说话了(不然全部用例都是假绿)', () => {
it('初次装载停在真实底部,而且这个数不是 0', async () => {
render(chatPaneEl(history(), false));
await flushFrames();
expect(maxScrollTop()).toBe(3_400);
expect(geom.scrollTop).toBe(3_400);
});
it('钉住位置和贴底位置是两个不同的数', async () => {
render(chatPaneEl(history(), false));
await flushFrames();
arriveNewUserTurn();
expect(anchoredScrollTop()).toBe(4_000 - ANCHOR_TOP_PADDING);
expect(anchoredScrollTop()).not.toBe(maxScrollTop());
});
});
/*
* ── 缺陷一:入口没接 ────────────────────────────────────────────────
*
* 这一格代表**所有不走输入框的入口**:question-form 交答案、首页发起后自动送出、
* 批注发起、队列排到、失败后的「继续」、生图重试。它们的共同形状就是这个 ——
* 宿主直接把新的用户消息塞进 `messages`,`ChatPane` 的输入框从头到尾没参与。
*/
describe('不走输入框的入口:新一轮照样要钉到顶', () => {
it('宿主直接塞进来的新用户消息,必须钉在顶端而不是留在底部', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushFrames();
expect(geom.scrollTop).toBe(3_400);
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
await advanceSmoothScroll();
expect(geom.scrollTop).toBe(anchoredScrollTop());
});
it('并且撑出占位块 —— 否则这条消息物理上根本滚不到顶', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushFrames();
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
// 消息下面只有它自己那 80px:600 80 12 = 508。
expect(tailSpacerHeight()).toBe(508);
});
it('空会话的第一条(首页发起走的就是这一格)也要钉到顶', async () => {
geom.contentHeight = 0;
geom.lastUserTopInContent = 0;
const { rerender } = render(chatPaneEl([], false));
await flushFrames();
geom.contentHeight = USER_MSG_H;
geom.lastUserTopInContent = 0;
await act(async () => {
rerender(chatPaneEl(
[{ id: 'u-home', role: 'user', content: 'from home', createdAt: 1 }],
true,
));
});
await flushFrames();
await advanceSmoothScroll();
expect(tailSpacerHeight()).toBe(VIEWPORT - USER_MSG_H - ANCHOR_TOP_PADDING);
});
it('整篇转录初次装载不算新一轮 —— 不许把历史会话拽到某条消息的顶端', async () => {
render(chatPaneEl(history(), false));
await flushFrames();
expect(geom.scrollTop).toBe(maxScrollTop());
expect(tailSpacerHeight()).toBe(0);
});
});
/*
* ── 缺陷二:钉住这一跳用了平滑滚动,于是自己把自己判掉 ──────────────────
*
* 走的是**真输入框**,所以在修复之前这几格里 `anchorPendingRef` 是被正常举手的 ——
* 它们照出来的只可能是滚法本身的问题,和「入口没接」那一半互不遮掩。
*/
describe('输入框发出的一轮:整轮都要留在顶端', () => {
async function sendFromComposer() {
await typeAndSettle('make me a poster');
pressEnter();
await act(async () => {
await Promise.resolve();
});
}
it('回复迟迟不来的那一轮,钉住之后不许被贴底跟随抢回去', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushMounts();
await flushFrames();
await sendFromComposer();
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
// 回复还没开始吐字 —— 动画整段跑完,落点正好是底部。
await advanceSmoothScroll();
// 现在回复来了,内容长高 500px。
geom.contentHeight += 500;
await act(async () => {
rerender(chatPaneEl(withNewTurn('a'.repeat(400)), true));
});
await flushFrames();
await triggerResize();
expect(geom.scrollTop).toBe(anchoredScrollTop());
});
it('回复长出来的时候占位块要跟着收缩,不能留一块死空白', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushMounts();
await flushFrames();
await sendFromComposer();
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
await advanceSmoothScroll();
expect(tailSpacerHeight()).toBe(508);
geom.contentHeight += 500;
await act(async () => {
rerender(chatPaneEl(withNewTurn('a'.repeat(400)), true));
});
await flushFrames();
await triggerResize();
// 消息下面已经有 80 + 500 = 580 的真内容,只差 600 580 12 = 8。
expect(tailSpacerHeight()).toBe(8);
});
/*
* 钉住这一跳必须**当拍落地**,不能留一段动画在飞。判据看的是「位置」,而动画
* 的中间帧全都在落点之外 —— 只要还有一段动画要跑,这套机制就会自己把自己判掉。
*
* 所以这里不去嗅「调用时传了哪个 behavior」(那是实现细节),而是钉可观测的
* 结果:这一帧过完,位置已经在落点上,且没有任何平滑动画在等着跑。
*/
it('钉住这一跳当拍就落地 —— 不留一段平滑动画在飞', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushMounts();
await flushFrames();
await sendFromComposer();
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
expect(geom.scrollTop).toBe(anchoredScrollTop());
expect(pendingSmooth).toBeNull();
expect(scrollToBehaviors).not.toContain('smooth');
});
it('钉住之后浏览器补发的那个 scroll 事件,不许被当成用户滚开', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushMounts();
await flushFrames();
await sendFromComposer();
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
await advanceSmoothScroll();
// 浏览器对着落点补一个 scroll 事件(我们自己写 scrollTop 也会有这一下)。
await act(async () => {
fireEvent.scroll(chatLog());
await Promise.resolve();
});
// 还钉着 = 占位块继续跟着回复收缩。
geom.contentHeight += 500;
await act(async () => {
rerender(chatPaneEl(withNewTurn('a'.repeat(400)), true));
});
await flushFrames();
await triggerResize();
expect(tailSpacerHeight()).toBe(8);
expect(geom.scrollTop).toBe(anchoredScrollTop());
});
});
/*
* ── 用户真的自己滚开时,仍然要松手 ──────────────────────────────────
*
* 上面那组把「我们自己滚」从判据里摘出去了。这一格钉的是它没有摘过头:
* 用户的手一动,钉住状态照样要放。
*/
describe('用户自己滚开就松手', () => {
it('往上滚出容差之后,占位块不再跟着回复收缩', async () => {
const { rerender } = render(chatPaneEl(history(), false));
await flushFrames();
arriveNewUserTurn();
await act(async () => {
rerender(chatPaneEl(withNewTurn(null), true));
});
await flushFrames();
await advanceSmoothScroll();
expect(tailSpacerHeight()).toBe(508);
// 用户往上翻了 300px 去看更早的内容。
await act(async () => {
geom.scrollTop = anchoredScrollTop() - 300;
fireEvent.scroll(chatLog());
await Promise.resolve();
});
geom.contentHeight += 500;
await act(async () => {
rerender(chatPaneEl(withNewTurn('a'.repeat(400)), true));
});
await flushFrames();
await triggerResize();
// 预留的空白原地不动 —— 它已经是用户脚下真实的可滚区域,收掉会把画面抽走。
expect(tailSpacerHeight()).toBe(508);
});
});
@@ -403,7 +403,17 @@ describe('chat assistant feedback', () => {
expect(screen.queryByRole('button', { name: 'Other' })).toBeNull();
});
it('scrolls the feedback reasons panel into view after selecting a rating', () => {
/**
* Rating a reply must not move the view when the panel is already on screen.
*
* `block: 'start'` pulls the panel to the top of the scroller whether or not
* it needed pulling, which reads as the page jumping away from what the user
* was looking at; `smooth` then animates that jump, and the animation's own
* frames look exactly like a user scroll to whoever is watching scroll
* position. `nearest` scrolls the minimum required — nothing at all when the
* panel is already visible, which is the common case.
*/
it('brings the feedback reasons panel into view without yanking the log', () => {
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
@@ -413,7 +423,7 @@ describe('chat assistant feedback', () => {
fireEvent.click(screen.getByRole('button', { name: 'Not helpful' }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', behavior: 'smooth' });
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', behavior: 'auto' });
});
it('does not ask for feedback while the assistant is still running', () => {
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import {
ANCHOR_RELEASE_SLACK_PX,
ANCHOR_TOP_PADDING,
anchorReleasedByScroll,
anchorScrollTop,
anchorSpacerHeight,
isNewTailUserTurn,
maxScrollTopAfterAnchorSpacer,
type AnchorGeometry,
} from '../../../src/runtime/chat/anchor-to-top';
/** 一屏 600px 的面板,刚发出的消息落在 4000px 处,下面还没有任何回复。 */
const freshTurn: AnchorGeometry = {
clientHeight: 600,
scrollHeight: 4_080,
spacerHeight: 0,
messageTopInContent: 4_000,
};
describe('尾部占位块:撑出「刚好够钉到顶」的空白', () => {
it('回复还没来时,要补上差不多一整屏', () => {
// 消息下面只有它自己那 80px,想让它顶到上沿还差 600 80 12 = 508。
expect(anchorSpacerHeight(freshTurn)).toBe(508);
});
it('回复长出来之后单调收缩', () => {
// `scrollHeight` 是含占位块的读数,所以真内容 4_380 + 占位块 508。
const growing = { ...freshTurn, scrollHeight: 4_380 + 508, spacerHeight: 508 };
expect(anchorSpacerHeight(growing)).toBe(208);
const longer = { ...freshTurn, scrollHeight: 4_780 + 208, spacerHeight: 208 };
expect(anchorSpacerHeight(longer)).toBe(0);
});
it('回复已经比一屏还长时不再预留', () => {
const overflowing = { ...freshTurn, scrollHeight: 4_080 + 2_000, spacerHeight: 0 };
expect(anchorSpacerHeight(overflowing)).toBe(0);
});
});
describe('落点', () => {
it('顶到上沿,上面留 ANCHOR_TOP_PADDING', () => {
expect(anchorScrollTop(4_000)).toBe(4_000 - ANCHOR_TOP_PADDING);
expect(ANCHOR_TOP_PADDING).toBe(12);
});
it('会话开头的消息滚不出负数', () => {
expect(anchorScrollTop(4)).toBe(0);
});
/*
* 这条恒等式是整套机制的地基:占位块正好撑到 ==
* ,,
*/
it('【不变量】占位块定完尺寸后,落点就是能滚到的最大位置', () => {
for (const scrollHeight of [4_080, 4_200, 4_380, 4_600, 6_000]) {
const geometry = { ...freshTurn, scrollHeight, spacerHeight: 0 };
const settled = { ...geometry, spacerHeight: anchorSpacerHeight(geometry) };
if (anchorSpacerHeight(geometry) === 0) {
// 回复已经够长,顶端够得着,落点在最大位置之内即可。
expect(maxScrollTopAfterAnchorSpacer(settled)).toBeGreaterThanOrEqual(
anchorScrollTop(settled.messageTopInContent),
);
continue;
}
expect(maxScrollTopAfterAnchorSpacer(settled)).toBe(
anchorScrollTop(settled.messageTopInContent),
);
}
});
});
describe('松手判据', () => {
it('落在钉住位置上不算滚开', () => {
expect(
anchorReleasedByScroll({ scrollTop: 3_988, messageTopInContent: 4_000 }),
).toBe(false);
});
it('容差之内的漂移不算滚开', () => {
expect(
anchorReleasedByScroll({
scrollTop: 3_988 + ANCHOR_RELEASE_SLACK_PX,
messageTopInContent: 4_000,
}),
).toBe(false);
});
it('超过容差才算', () => {
expect(
anchorReleasedByScroll({
scrollTop: 3_988 + ANCHOR_RELEASE_SLACK_PX + 1,
messageTopInContent: 4_000,
}),
).toBe(true);
});
/*
* 平滑滚动为什么不能用:动画中间的每一帧都离落点很远
*
*/
it('平滑动画的中间帧会被判成用户滚开 —— 所以这一跳必须瞬时', () => {
const from = 3_400;
const to = anchorScrollTop(4_000);
const midway = Math.round(from + (to - from) * 0.25);
expect(
anchorReleasedByScroll({ scrollTop: midway, messageTopInContent: 4_000 }),
).toBe(true);
});
});
describe('该不该钉顶:只认「尾条用户消息换人了」', () => {
it('初次装载整篇转录不算新一轮', () => {
expect(isNewTailUserTurn(undefined, 'u8')).toBe(false);
});
it('换会话之后的第一拍不算', () => {
expect(isNewTailUserTurn(undefined, 'other-conversation-tail')).toBe(false);
});
it('空会话落定成 null 之后,第一条用户消息算新一轮(首页发起走这一格)', () => {
expect(isNewTailUserTurn(null, 'u1')).toBe(true);
});
it('尾条换了就算 —— 不问它是从哪个入口发出来的', () => {
expect(isNewTailUserTurn('u7', 'u8')).toBe(true);
});
it('助手流式期间尾条没变,不重复钉', () => {
expect(isNewTailUserTurn('u8', 'u8')).toBe(false);
});
it('重试(不产生新用户消息)不算新一轮', () => {
expect(isNewTailUserTurn('u8', 'u8')).toBe(false);
});
it('会话被清空不算', () => {
expect(isNewTailUserTurn('u8', null)).toBe(false);
});
});