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
@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { ChatScrollSessionStorage } from "@/lib/chat/chat_scroll_session_storage";
import {
consumeChatScrollSnapshot,
getChatScrollRestoreTop,
saveChatScrollSnapshot,
} from "../chat-scroll-session";
afterEach(async () => {
await consumeChatScrollSnapshot();
await ChatScrollSessionStorage.clearSnapshot();
document.body.innerHTML = "";
});
describe("chat scroll session", () => {
it("saves image viewer scroll sessions by message anchor", async () => {
const { anchorNode, scrollNode } = createScrollFixture({
anchorTop: 220,
scrollTop: 120,
});
saveChatScrollSnapshot(scrollNode, { anchorMessageId: "msg-image-1" });
const snapshot = await consumeChatScrollSnapshot();
expect(snapshot).toEqual({
version: 2,
reason: "image-viewer",
anchorMessageId: "msg-image-1",
anchorOffsetTop: 120,
fallbackScrollTop: 120,
savedAt: expect.any(Number),
});
expect(anchorNode.dataset.chatMessageId).toBe("msg-image-1");
});
it("restores scroll top by keeping the anchor at the saved visual offset", () => {
const { scrollNode } = createScrollFixture({
anchorTop: 250,
scrollTop: 200,
});
expect(
getChatScrollRestoreTop(scrollNode, {
version: 2,
reason: "image-viewer",
anchorMessageId: "msg-image-1",
anchorOffsetTop: 120,
fallbackScrollTop: 120,
savedAt: Date.now(),
}),
).toBe(230);
});
it("falls back to the saved scroll top when the anchor is missing", () => {
const { scrollNode } = createScrollFixture({
anchorTop: 250,
scrollTop: 200,
});
expect(
getChatScrollRestoreTop(scrollNode, {
version: 2,
reason: "image-viewer",
anchorMessageId: "missing-message",
anchorOffsetTop: 120,
fallbackScrollTop: 180,
savedAt: Date.now(),
}),
).toBe(180);
});
});
function createScrollFixture(input: {
anchorTop: number;
scrollTop: number;
}): {
anchorNode: HTMLElement;
scrollNode: HTMLElement;
} {
const scrollNode = document.createElement("div");
Object.defineProperties(scrollNode, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1000 },
scrollTop: {
configurable: true,
value: input.scrollTop,
writable: true,
},
});
scrollNode.getBoundingClientRect = () => makeRect(100);
const anchorNode = document.createElement("div");
anchorNode.dataset.chatMessageId = "msg-image-1";
anchorNode.getBoundingClientRect = () => makeRect(input.anchorTop);
scrollNode.appendChild(anchorNode);
document.body.appendChild(scrollNode);
return { anchorNode, scrollNode };
}
function makeRect(top: number): DOMRect {
return {
bottom: top + 40,
height: 40,
left: 0,
right: 100,
toJSON: () => ({}),
top,
width: 100,
x: 0,
y: top,
};
}
+89 -29
View File
@@ -8,35 +8,57 @@ import { Result } from "@/utils";
const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll"; const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll";
const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000; const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000;
const CHAT_SCROLL_BOTTOM_THRESHOLD = 80;
let memoryScrollSession: ChatScrollSession | null = null; let memoryScrollSession: ChatScrollSession | null = null;
export function requestChatScrollSnapshotSave(): void { interface ChatScrollSnapshotSaveDetail {
anchorMessageId: string;
}
export function requestChatScrollSnapshotSave(anchorMessageId: string): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.dispatchEvent(new Event(CHAT_SCROLL_SAVE_EVENT)); if (anchorMessageId.trim().length === 0) return;
window.dispatchEvent(
new CustomEvent<ChatScrollSnapshotSaveDetail>(CHAT_SCROLL_SAVE_EVENT, {
detail: { anchorMessageId },
}),
);
} }
export function listenChatScrollSnapshotSave( export function listenChatScrollSnapshotSave(
listener: () => void, listener: (detail: ChatScrollSnapshotSaveDetail) => void,
): () => void { ): () => void {
if (typeof window === "undefined") return () => {}; if (typeof window === "undefined") return () => {};
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, listener); const handler = (event: Event) => {
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, listener); if (!(event instanceof CustomEvent)) return;
const detail = event.detail as Partial<ChatScrollSnapshotSaveDetail>;
if (typeof detail.anchorMessageId !== "string") return;
listener({ anchorMessageId: detail.anchorMessageId });
};
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, handler);
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, handler);
} }
export function saveChatScrollSnapshot( export function saveChatScrollSnapshot(
scrollNode: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">, scrollNode: HTMLElement,
detail: ChatScrollSnapshotSaveDetail,
): void { ): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const { scrollTop, scrollHeight, clientHeight } = scrollNode; const { scrollTop } = scrollNode;
if (!Number.isFinite(scrollTop) || scrollTop < 0) return; if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
const anchorNode = findChatMessageElement(
scrollNode,
detail.anchorMessageId,
);
if (!anchorNode) return;
const payload: ChatScrollSession = { const payload: ChatScrollSession = {
scrollTop, version: 2,
scrollHeight, reason: "image-viewer",
clientHeight, anchorMessageId: detail.anchorMessageId,
distanceFromBottom: Math.max(0, scrollHeight - scrollTop - clientHeight), anchorOffsetTop: getElementOffsetTop(scrollNode, anchorNode),
fallbackScrollTop: scrollTop,
savedAt: Date.now(), savedAt: Date.now(),
}; };
memoryScrollSession = payload; memoryScrollSession = payload;
@@ -60,14 +82,22 @@ export async function consumeChatScrollSnapshot(): Promise<ChatScrollSession | n
} }
export function getChatScrollRestoreTop( export function getChatScrollRestoreTop(
scrollNode: Pick<HTMLElement, "scrollHeight" | "clientHeight">, scrollNode: HTMLElement,
snapshot: ChatScrollSession, snapshot: ChatScrollSession,
): number { ): number {
const maxScrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight); const anchorNode = findChatMessageElement(
if (snapshot.distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD) { scrollNode,
return maxScrollTop; snapshot.anchorMessageId,
);
if (anchorNode) {
const currentOffsetTop = getElementOffsetTop(scrollNode, anchorNode);
return clampScrollTop(
scrollNode,
scrollNode.scrollTop + currentOffsetTop - snapshot.anchorOffsetTop,
);
} }
return Math.min(Math.max(0, snapshot.scrollTop), maxScrollTop);
return clampScrollTop(scrollNode, snapshot.fallbackScrollTop);
} }
function readValidChatScrollSession(value: unknown): ChatScrollSession | null { function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
@@ -75,14 +105,14 @@ function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
const payload = value as Partial<ChatScrollSession>; const payload = value as Partial<ChatScrollSession>;
if ( if (
typeof payload.scrollTop !== "number" || payload.version !== 2 ||
!Number.isFinite(payload.scrollTop) || payload.reason !== "image-viewer" ||
typeof payload.scrollHeight !== "number" || typeof payload.anchorMessageId !== "string" ||
!Number.isFinite(payload.scrollHeight) || payload.anchorMessageId.length === 0 ||
typeof payload.clientHeight !== "number" || typeof payload.anchorOffsetTop !== "number" ||
!Number.isFinite(payload.clientHeight) || !Number.isFinite(payload.anchorOffsetTop) ||
typeof payload.distanceFromBottom !== "number" || typeof payload.fallbackScrollTop !== "number" ||
!Number.isFinite(payload.distanceFromBottom) || !Number.isFinite(payload.fallbackScrollTop) ||
typeof payload.savedAt !== "number" || typeof payload.savedAt !== "number" ||
!Number.isFinite(payload.savedAt) !Number.isFinite(payload.savedAt)
) { ) {
@@ -94,10 +124,40 @@ function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
} }
return { return {
scrollTop: Math.max(0, payload.scrollTop), version: 2,
scrollHeight: Math.max(0, payload.scrollHeight), reason: "image-viewer",
clientHeight: Math.max(0, payload.clientHeight), anchorMessageId: payload.anchorMessageId,
distanceFromBottom: Math.max(0, payload.distanceFromBottom), anchorOffsetTop: payload.anchorOffsetTop,
fallbackScrollTop: Math.max(0, payload.fallbackScrollTop),
savedAt: payload.savedAt, savedAt: payload.savedAt,
}; };
} }
function findChatMessageElement(
scrollNode: HTMLElement,
messageId: string,
): HTMLElement | null {
return (
Array.from(
scrollNode.querySelectorAll<HTMLElement>("[data-chat-message-id]"),
).find((element) => element.dataset.chatMessageId === messageId) ?? null
);
}
function getElementOffsetTop(
scrollNode: HTMLElement,
element: HTMLElement,
): number {
return (
element.getBoundingClientRect().top -
scrollNode.getBoundingClientRect().top
);
}
function clampScrollTop(scrollNode: HTMLElement, value: number): number {
const maxScrollTop = Math.max(
0,
scrollNode.scrollHeight - scrollNode.clientHeight,
);
return Math.min(Math.max(0, value), maxScrollTop);
}
+50 -26
View File
@@ -28,6 +28,8 @@ import { LottieMessageBubble } from "./lottie-message-bubble";
import { MessageBubble } from "./message-bubble"; import { MessageBubble } from "./message-bubble";
import styles from "./chat-area.module.css"; import styles from "./chat-area.module.css";
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
export interface ChatAreaProps { export interface ChatAreaProps {
messages: readonly UiMessage[]; messages: readonly UiMessage[];
isReplyingAI: boolean; isReplyingAI: boolean;
@@ -49,30 +51,31 @@ 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 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; const scrollNode = scrollRef.current;
if (!scrollNode) return; if (!scrollNode) return;
saveChatScrollSnapshot(scrollNode); saveChatScrollSnapshot(scrollNode, { anchorMessageId });
};
const saveCurrentScrollSnapshot = () => {
if (saveFrameRef.current !== null) return;
saveFrameRef.current = window.requestAnimationFrame(() => {
saveFrameRef.current = null;
saveCurrentScrollSnapshotNow();
});
}; };
// 新消息 → 滚到底部 // 新消息 → 滚到底部
useEffect(() => { useEffect(() => {
if (messages.length !== prevLengthRef.current) { if (messages.length !== prevLengthRef.current) {
scrollRef.current?.scrollTo({ const scrollNode = scrollRef.current;
top: scrollRef.current.scrollHeight, const shouldSkipAutoScroll =
restoreStateRef.current === "checking" ||
restoreStateRef.current === "restoring";
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
scrollNode.scrollTo({
top: scrollNode.scrollHeight,
behavior: "smooth", behavior: "smooth",
}); });
}
prevLengthRef.current = messages.length; prevLengthRef.current = messages.length;
} }
}, [messages.length]); }, [messages.length]);
@@ -88,13 +91,28 @@ export function ChatArea({
let timer: number | null = null; let timer: number | null = null;
let observerTimer: number | null = null; let observerTimer: number | null = null;
let lateRestoreTimer: number | null = null; let lateRestoreTimer: number | null = null;
let finishRestoreTimer: number | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
restoreStateRef.current = "checking";
const restore = async () => { const restore = async () => {
const snapshot = await consumeChatScrollSnapshot(); 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; restoredScrollRef.current = true;
restoreStateRef.current = "restoring";
prevLengthRef.current = messages.length;
const restoreScroll = () => { const restoreScroll = () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot); scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
@@ -114,6 +132,10 @@ export function ChatArea({
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}, 800); }, 800);
lateRestoreTimer = window.setTimeout(restoreScroll, 600); lateRestoreTimer = window.setTimeout(restoreScroll, 600);
finishRestoreTimer = window.setTimeout(() => {
restoreStateRef.current = "done";
wasNearBottomRef.current = isNearBottom(scrollNode);
}, 850);
}; };
void restore(); void restore();
@@ -124,24 +146,18 @@ export function ChatArea({
if (timer !== null) window.clearTimeout(timer); if (timer !== null) window.clearTimeout(timer);
if (observerTimer !== null) window.clearTimeout(observerTimer); if (observerTimer !== null) window.clearTimeout(observerTimer);
if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer); if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer);
if (finishRestoreTimer !== null) window.clearTimeout(finishRestoreTimer);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}; };
}, [messages.length]); }, [messages.length]);
useEffect(() => { useEffect(() => {
const scrollNode = scrollRef.current; const stopListening = listenChatScrollSnapshotSave(({ anchorMessageId }) => {
const stopListening = listenChatScrollSnapshotSave( saveCurrentScrollSnapshotNow(anchorMessageId);
saveCurrentScrollSnapshotNow, });
);
return () => { return () => {
stopListening(); 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} ref={scrollRef}
className={styles.area} className={styles.area}
aria-label="Chat messages" aria-label="Chat messages"
onScroll={saveCurrentScrollSnapshot} onScroll={(event) => {
wasNearBottomRef.current = isNearBottom(event.currentTarget);
}}
> >
<AiDisclosureBanner /> <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( function renderMessagesWithDateHeaders(
messages: readonly UiMessage[], messages: readonly UiMessage[],
+1 -1
View File
@@ -57,7 +57,7 @@ export function ImageBubble({
const openImage = () => { const openImage = () => {
if (!messageId) return; if (!messageId) return;
requestChatScrollSnapshotSave(); requestChatScrollSnapshotSave(messageId);
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false }); router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
}; };
+10 -2
View File
@@ -49,7 +49,11 @@ export function MessageBubble({
if (isFromAI) { if (isFromAI) {
return ( 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} /> <MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} /> <div style={{ width: 8 }} />
<MessageContent <MessageContent
@@ -73,7 +77,11 @@ export function MessageBubble({
} }
return ( 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 }} /> {/* 占位 */} <div style={{ width: 43 }} /> {/* 占位 */}
<MessageContent <MessageContent
content={content} content={content}
+5 -4
View File
@@ -6,10 +6,11 @@ import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil, type Result as ResultT } from "@/utils"; import { Result, SessionAsyncUtil, type Result as ResultT } from "@/utils";
const ChatScrollSessionSchema = z.object({ const ChatScrollSessionSchema = z.object({
scrollTop: z.number(), version: z.literal(2),
scrollHeight: z.number(), reason: z.literal("image-viewer"),
clientHeight: z.number(), anchorMessageId: z.string().min(1),
distanceFromBottom: z.number(), anchorOffsetTop: z.number(),
fallbackScrollTop: z.number(),
savedAt: z.number(), savedAt: z.number(),
}); });