fix(chat): restore image message scroll anchor

This commit is contained in:
2026-07-02 10:44:43 +08:00
parent c20769b6a3
commit 2b0129679f
6 changed files with 273 additions and 64 deletions
+52 -28
View File
@@ -28,6 +28,8 @@ import { LottieMessageBubble } from "./lottie-message-bubble";
import { MessageBubble } from "./message-bubble";
import styles from "./chat-area.module.css";
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
@@ -49,30 +51,31 @@ export function ChatArea({
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const restoredScrollRef = useRef(false);
const saveFrameRef = useRef<number | null>(null);
const restoreStateRef = useRef<"idle" | "checking" | "restoring" | "done">(
"idle",
);
const wasNearBottomRef = useRef(true);
const saveCurrentScrollSnapshotNow = () => {
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
const scrollNode = scrollRef.current;
if (!scrollNode) return;
saveChatScrollSnapshot(scrollNode);
};
const saveCurrentScrollSnapshot = () => {
if (saveFrameRef.current !== null) return;
saveFrameRef.current = window.requestAnimationFrame(() => {
saveFrameRef.current = null;
saveCurrentScrollSnapshotNow();
});
saveChatScrollSnapshot(scrollNode, { anchorMessageId });
};
// 新消息 → 滚到底部
useEffect(() => {
if (messages.length !== prevLengthRef.current) {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
const scrollNode = scrollRef.current;
const shouldSkipAutoScroll =
restoreStateRef.current === "checking" ||
restoreStateRef.current === "restoring";
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
scrollNode.scrollTo({
top: scrollNode.scrollHeight,
behavior: "smooth",
});
}
prevLengthRef.current = messages.length;
}
}, [messages.length]);
@@ -88,13 +91,28 @@ export function ChatArea({
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";
const restore = async () => {
const snapshot = await consumeChatScrollSnapshot();
if (cancelled || snapshot === null) return;
if (cancelled) return;
if (snapshot === null) {
restoredScrollRef.current = true;
restoreStateRef.current = "done";
scrollNode.scrollTo({
top: scrollNode.scrollHeight,
behavior: "auto",
});
return;
}
restoredScrollRef.current = true;
restoreStateRef.current = "restoring";
prevLengthRef.current = messages.length;
const restoreScroll = () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
@@ -114,6 +132,10 @@ export function ChatArea({
resizeObserver?.disconnect();
}, 800);
lateRestoreTimer = window.setTimeout(restoreScroll, 600);
finishRestoreTimer = window.setTimeout(() => {
restoreStateRef.current = "done";
wasNearBottomRef.current = isNearBottom(scrollNode);
}, 850);
};
void restore();
@@ -124,24 +146,18 @@ export function ChatArea({
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]);
useEffect(() => {
const scrollNode = scrollRef.current;
const stopListening = listenChatScrollSnapshotSave(
saveCurrentScrollSnapshotNow,
);
const stopListening = listenChatScrollSnapshotSave(({ anchorMessageId }) => {
saveCurrentScrollSnapshotNow(anchorMessageId);
});
return () => {
stopListening();
if (saveFrameRef.current !== null) {
window.cancelAnimationFrame(saveFrameRef.current);
saveFrameRef.current = null;
}
if (!scrollNode) return;
saveChatScrollSnapshot(scrollNode);
};
}, []);
@@ -150,7 +166,9 @@ export function ChatArea({
ref={scrollRef}
className={styles.area}
aria-label="Chat messages"
onScroll={saveCurrentScrollSnapshot}
onScroll={(event) => {
wasNearBottomRef.current = isNearBottom(event.currentTarget);
}}
>
<AiDisclosureBanner />
@@ -167,6 +185,12 @@ export function ChatArea({
);
}
function isNearBottom(scrollNode: HTMLElement): boolean {
const distanceFromBottom =
scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight;
return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD;
}
/** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],
+1 -1
View File
@@ -57,7 +57,7 @@ export function ImageBubble({
const openImage = () => {
if (!messageId) return;
requestChatScrollSnapshotSave();
requestChatScrollSnapshotSave(messageId);
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
};
+10 -2
View File
@@ -49,7 +49,11 @@ export function MessageBubble({
if (isFromAI) {
return (
<div className={styles.bubbleRowAi} aria-label="AI message">
<div
className={styles.bubbleRowAi}
data-chat-message-id={messageId}
aria-label="AI message"
>
<MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} />
<MessageContent
@@ -73,7 +77,11 @@ export function MessageBubble({
}
return (
<div className={styles.bubbleRowUser} aria-label="User message">
<div
className={styles.bubbleRowUser}
data-chat-message-id={messageId}
aria-label="User message"
>
<div style={{ width: 43 }} /> {/* 占位 */}
<MessageContent
content={content}