fix(chat): settle scroll after history load
This commit is contained in:
@@ -30,6 +30,8 @@ import styles from "./chat-area.module.css";
|
|||||||
|
|
||||||
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
||||||
|
|
||||||
|
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
|
||||||
|
|
||||||
export interface ChatAreaProps {
|
export interface ChatAreaProps {
|
||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
@@ -51,9 +53,7 @@ export function ChatArea({
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLengthRef = useRef(messages.length);
|
const prevLengthRef = useRef(messages.length);
|
||||||
const restoredScrollRef = useRef(false);
|
const restoredScrollRef = useRef(false);
|
||||||
const restoreStateRef = useRef<"idle" | "checking" | "restoring" | "done">(
|
const restoreStateRef = useRef<RestoreState>("idle");
|
||||||
"idle",
|
|
||||||
);
|
|
||||||
const wasNearBottomRef = useRef(true);
|
const wasNearBottomRef = useRef(true);
|
||||||
|
|
||||||
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
||||||
@@ -68,7 +68,8 @@ export function ChatArea({
|
|||||||
const scrollNode = scrollRef.current;
|
const scrollNode = scrollRef.current;
|
||||||
const shouldSkipAutoScroll =
|
const shouldSkipAutoScroll =
|
||||||
restoreStateRef.current === "checking" ||
|
restoreStateRef.current === "checking" ||
|
||||||
restoreStateRef.current === "restoring";
|
restoreStateRef.current === "restoring" ||
|
||||||
|
restoreStateRef.current === "settlingBottom";
|
||||||
|
|
||||||
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
|
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
|
||||||
scrollNode.scrollTo({
|
scrollNode.scrollTo({
|
||||||
@@ -87,12 +88,7 @@ export function ChatArea({
|
|||||||
if (!scrollNode) return;
|
if (!scrollNode) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let frame: number | null = null;
|
let stopStableScrollTask: (() => void) | 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;
|
|
||||||
|
|
||||||
restoreStateRef.current = "checking";
|
restoreStateRef.current = "checking";
|
||||||
|
|
||||||
@@ -102,10 +98,17 @@ export function ChatArea({
|
|||||||
|
|
||||||
if (snapshot === null) {
|
if (snapshot === null) {
|
||||||
restoredScrollRef.current = true;
|
restoredScrollRef.current = true;
|
||||||
restoreStateRef.current = "done";
|
restoreStateRef.current = "settlingBottom";
|
||||||
scrollNode.scrollTo({
|
prevLengthRef.current = messages.length;
|
||||||
top: scrollNode.scrollHeight,
|
stopStableScrollTask = startStableScrollTask({
|
||||||
behavior: "auto",
|
scrollNode,
|
||||||
|
applyScroll: () => {
|
||||||
|
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
restoreStateRef.current = "done";
|
||||||
|
wasNearBottomRef.current = true;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,40 +117,23 @@ export function ChatArea({
|
|||||||
restoreStateRef.current = "restoring";
|
restoreStateRef.current = "restoring";
|
||||||
prevLengthRef.current = messages.length;
|
prevLengthRef.current = messages.length;
|
||||||
|
|
||||||
const restoreScroll = () => {
|
stopStableScrollTask = startStableScrollTask({
|
||||||
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
|
scrollNode,
|
||||||
};
|
applyScroll: () => {
|
||||||
|
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
|
||||||
restoreScroll();
|
},
|
||||||
frame = window.requestAnimationFrame(restoreScroll);
|
onFinish: () => {
|
||||||
timer = window.setTimeout(restoreScroll, 120);
|
restoreStateRef.current = "done";
|
||||||
resizeObserver =
|
wasNearBottomRef.current = isNearBottom(scrollNode);
|
||||||
typeof ResizeObserver === "undefined"
|
},
|
||||||
? null
|
|
||||||
: new ResizeObserver(restoreScroll);
|
|
||||||
Array.from(scrollNode.children).forEach((child) => {
|
|
||||||
resizeObserver?.observe(child);
|
|
||||||
});
|
});
|
||||||
observerTimer = window.setTimeout(() => {
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
}, 800);
|
|
||||||
lateRestoreTimer = window.setTimeout(restoreScroll, 600);
|
|
||||||
finishRestoreTimer = window.setTimeout(() => {
|
|
||||||
restoreStateRef.current = "done";
|
|
||||||
wasNearBottomRef.current = isNearBottom(scrollNode);
|
|
||||||
}, 850);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void restore();
|
void restore();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
stopStableScrollTask?.();
|
||||||
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();
|
|
||||||
};
|
};
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
@@ -191,6 +177,47 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
|
|||||||
return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD;
|
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(
|
function renderMessagesWithDateHeaders(
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
|
|||||||
Reference in New Issue
Block a user