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_MAX_AGE_MS = 10 * 60 * 1000;
const CHAT_SCROLL_BOTTOM_THRESHOLD = 80;
let memoryScrollSession: ChatScrollSession | null = null;
export function requestChatScrollSnapshotSave(): void {
interface ChatScrollSnapshotSaveDetail {
anchorMessageId: string;
}
export function requestChatScrollSnapshotSave(anchorMessageId: string): void {
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(
listener: () => void,
listener: (detail: ChatScrollSnapshotSaveDetail) => void,
): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, listener);
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, listener);
const handler = (event: Event) => {
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(
scrollNode: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
scrollNode: HTMLElement,
detail: ChatScrollSnapshotSaveDetail,
): void {
if (typeof window === "undefined") return;
const { scrollTop, scrollHeight, clientHeight } = scrollNode;
const { scrollTop } = scrollNode;
if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
const anchorNode = findChatMessageElement(
scrollNode,
detail.anchorMessageId,
);
if (!anchorNode) return;
const payload: ChatScrollSession = {
scrollTop,
scrollHeight,
clientHeight,
distanceFromBottom: Math.max(0, scrollHeight - scrollTop - clientHeight),
version: 2,
reason: "image-viewer",
anchorMessageId: detail.anchorMessageId,
anchorOffsetTop: getElementOffsetTop(scrollNode, anchorNode),
fallbackScrollTop: scrollTop,
savedAt: Date.now(),
};
memoryScrollSession = payload;
@@ -60,14 +82,22 @@ export async function consumeChatScrollSnapshot(): Promise<ChatScrollSession | n
}
export function getChatScrollRestoreTop(
scrollNode: Pick<HTMLElement, "scrollHeight" | "clientHeight">,
scrollNode: HTMLElement,
snapshot: ChatScrollSession,
): number {
const maxScrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight);
if (snapshot.distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD) {
return maxScrollTop;
const anchorNode = findChatMessageElement(
scrollNode,
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 {
@@ -75,14 +105,14 @@ function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
const payload = value as Partial<ChatScrollSession>;
if (
typeof payload.scrollTop !== "number" ||
!Number.isFinite(payload.scrollTop) ||
typeof payload.scrollHeight !== "number" ||
!Number.isFinite(payload.scrollHeight) ||
typeof payload.clientHeight !== "number" ||
!Number.isFinite(payload.clientHeight) ||
typeof payload.distanceFromBottom !== "number" ||
!Number.isFinite(payload.distanceFromBottom) ||
payload.version !== 2 ||
payload.reason !== "image-viewer" ||
typeof payload.anchorMessageId !== "string" ||
payload.anchorMessageId.length === 0 ||
typeof payload.anchorOffsetTop !== "number" ||
!Number.isFinite(payload.anchorOffsetTop) ||
typeof payload.fallbackScrollTop !== "number" ||
!Number.isFinite(payload.fallbackScrollTop) ||
typeof payload.savedAt !== "number" ||
!Number.isFinite(payload.savedAt)
) {
@@ -94,10 +124,40 @@ function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
}
return {
scrollTop: Math.max(0, payload.scrollTop),
scrollHeight: Math.max(0, payload.scrollHeight),
clientHeight: Math.max(0, payload.clientHeight),
distanceFromBottom: Math.max(0, payload.distanceFromBottom),
version: 2,
reason: "image-viewer",
anchorMessageId: payload.anchorMessageId,
anchorOffsetTop: payload.anchorOffsetTop,
fallbackScrollTop: Math.max(0, payload.fallbackScrollTop),
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);
}
+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}
+5 -4
View File
@@ -6,10 +6,11 @@ import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil, type Result as ResultT } from "@/utils";
const ChatScrollSessionSchema = z.object({
scrollTop: z.number(),
scrollHeight: z.number(),
clientHeight: z.number(),
distanceFromBottom: z.number(),
version: z.literal(2),
reason: z.literal("image-viewer"),
anchorMessageId: z.string().min(1),
anchorOffsetTop: z.number(),
fallbackScrollTop: z.number(),
savedAt: z.number(),
});