fix(chat): settle scroll after history load

This commit is contained in:
2026-07-02 11:14:42 +08:00
parent 2b0129679f
commit 35c8de917d
+68 -41
View File
@@ -30,6 +30,8 @@ import styles from "./chat-area.module.css";
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
@@ -51,9 +53,7 @@ export function ChatArea({
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const restoredScrollRef = useRef(false);
const restoreStateRef = useRef<"idle" | "checking" | "restoring" | "done">(
"idle",
);
const restoreStateRef = useRef<RestoreState>("idle");
const wasNearBottomRef = useRef(true);
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
@@ -68,7 +68,8 @@ export function ChatArea({
const scrollNode = scrollRef.current;
const shouldSkipAutoScroll =
restoreStateRef.current === "checking" ||
restoreStateRef.current === "restoring";
restoreStateRef.current === "restoring" ||
restoreStateRef.current === "settlingBottom";
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
scrollNode.scrollTo({
@@ -87,12 +88,7 @@ export function ChatArea({
if (!scrollNode) return;
let cancelled = false;
let frame: number | null = null;
let timer: number | null = null;
let observerTimer: number | null = null;
let lateRestoreTimer: number | null = null;
let finishRestoreTimer: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let stopStableScrollTask: (() => void) | null = null;
restoreStateRef.current = "checking";
@@ -102,10 +98,17 @@ export function ChatArea({
if (snapshot === null) {
restoredScrollRef.current = true;
restoreStateRef.current = "done";
scrollNode.scrollTo({
top: scrollNode.scrollHeight,
behavior: "auto",
restoreStateRef.current = "settlingBottom";
prevLengthRef.current = messages.length;
stopStableScrollTask = startStableScrollTask({
scrollNode,
applyScroll: () => {
scrollNode.scrollTop = scrollNode.scrollHeight;
},
onFinish: () => {
restoreStateRef.current = "done";
wasNearBottomRef.current = true;
},
});
return;
}
@@ -114,40 +117,23 @@ export function ChatArea({
restoreStateRef.current = "restoring";
prevLengthRef.current = messages.length;
const restoreScroll = () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
};
restoreScroll();
frame = window.requestAnimationFrame(restoreScroll);
timer = window.setTimeout(restoreScroll, 120);
resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(restoreScroll);
Array.from(scrollNode.children).forEach((child) => {
resizeObserver?.observe(child);
stopStableScrollTask = startStableScrollTask({
scrollNode,
applyScroll: () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
},
onFinish: () => {
restoreStateRef.current = "done";
wasNearBottomRef.current = isNearBottom(scrollNode);
},
});
observerTimer = window.setTimeout(() => {
resizeObserver?.disconnect();
}, 800);
lateRestoreTimer = window.setTimeout(restoreScroll, 600);
finishRestoreTimer = window.setTimeout(() => {
restoreStateRef.current = "done";
wasNearBottomRef.current = isNearBottom(scrollNode);
}, 850);
};
void restore();
return () => {
cancelled = true;
if (frame !== null) window.cancelAnimationFrame(frame);
if (timer !== null) window.clearTimeout(timer);
if (observerTimer !== null) window.clearTimeout(observerTimer);
if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer);
if (finishRestoreTimer !== null) window.clearTimeout(finishRestoreTimer);
resizeObserver?.disconnect();
stopStableScrollTask?.();
};
}, [messages.length]);
@@ -191,6 +177,47 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD;
}
function startStableScrollTask({
applyScroll,
onFinish,
scrollNode,
}: {
applyScroll: () => void;
onFinish: () => void;
scrollNode: HTMLElement;
}): () => void {
let frame: number | null = null;
let earlyTimer: number | null = null;
let lateTimer: number | null = null;
let observerTimer: number | null = null;
let finishTimer: number | null = null;
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(applyScroll);
applyScroll();
frame = window.requestAnimationFrame(applyScroll);
earlyTimer = window.setTimeout(applyScroll, 120);
lateTimer = window.setTimeout(applyScroll, 600);
Array.from(scrollNode.children).forEach((child) => {
resizeObserver?.observe(child);
});
observerTimer = window.setTimeout(() => {
resizeObserver?.disconnect();
}, 800);
finishTimer = window.setTimeout(onFinish, 850);
return () => {
if (frame !== null) window.cancelAnimationFrame(frame);
if (earlyTimer !== null) window.clearTimeout(earlyTimer);
if (lateTimer !== null) window.clearTimeout(lateTimer);
if (observerTimer !== null) window.clearTimeout(observerTimer);
if (finishTimer !== null) window.clearTimeout(finishTimer);
resizeObserver?.disconnect();
};
}
/** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],