fix(chat): keep message list anchored to bottom

This commit is contained in:
2026-07-15 10:25:08 +08:00
parent 836ae95940
commit f60daf061d
4 changed files with 261 additions and 39 deletions
+3
View File
@@ -229,6 +229,9 @@ export function ChatScreen() {
<ChatArea
messages={visibleMessages}
isReplyingAI={state.isReplyingAI}
initialScrollReady={
state.historyLoaded && isPromotionBootstrapReady
}
isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
@@ -0,0 +1,180 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UiMessage } from "@/data/dto/chat";
import { ChatArea } from "../chat-area";
vi.mock("../message-bubble", () => ({
MessageBubble: ({ content }: { content: string }) => <div>{content}</div>,
}));
let scrollHeight = 0;
let clientHeight = 0;
let resizeObservers: MockResizeObserver[] = [];
class MockResizeObserver {
readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObservers.push(this);
}
observe(): void {}
disconnect(): void {}
trigger(): void {
this.callback([], this as unknown as ResizeObserver);
}
}
describe("ChatArea scrolling", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
scrollHeight = 0;
clientHeight = 0;
resizeObservers = [];
vi.stubGlobal("ResizeObserver", MockResizeObserver);
vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1));
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
function (this: HTMLElement) {
return this.getAttribute("aria-label") === "Chat messages"
? scrollHeight
: 0;
},
);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(
function (this: HTMLElement) {
return this.getAttribute("aria-label") === "Chat messages"
? clientHeight
: 0;
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("lands at the bottom on a normal entry", () => {
scrollHeight = 900;
clientHeight = 300;
renderChatArea(root, [createMessage("history-1")], true);
expect(getScrollNode(container).scrollTop).toBe(600);
});
it("waits for promotion bootstrap before settling at the final bottom", () => {
scrollHeight = 800;
clientHeight = 300;
const history = [createMessage("history-1")];
renderChatArea(root, history, false);
expect(getScrollNode(container).scrollTop).toBe(0);
scrollHeight = 1_200;
renderChatArea(
root,
[...history, createMessage("promotion-1")],
true,
);
expect(getScrollNode(container).scrollTop).toBe(900);
});
it("keeps the list bottom-anchored when rendered content grows", () => {
scrollHeight = 1_000;
clientHeight = 300;
renderChatArea(root, [createMessage("history-1")], true);
expect(getScrollNode(container).scrollTop).toBe(700);
scrollHeight = 1_400;
act(() => resizeObservers.forEach((observer) => observer.trigger()));
expect(getScrollNode(container).scrollTop).toBe(1_100);
});
it("follows new messages when the user remains near the bottom", () => {
scrollHeight = 1_000;
clientHeight = 300;
const history = [createMessage("history-1")];
renderChatArea(root, history, true);
const scrollNode = getScrollNode(container);
const scrollTo = vi.fn();
Object.defineProperty(scrollNode, "scrollTo", { value: scrollTo });
scrollNode.scrollTop = 650;
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
scrollHeight = 1_400;
renderChatArea(root, [...history, createMessage("reply-1")], true);
expect(scrollNode.scrollTop).toBe(1_100);
expect(scrollTo).not.toHaveBeenCalled();
});
it("preserves position after the user intentionally scrolls upward", () => {
scrollHeight = 1_000;
clientHeight = 300;
const history = [createMessage("history-1")];
renderChatArea(root, history, true);
const scrollNode = getScrollNode(container);
scrollNode.scrollTop = 100;
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
scrollHeight = 1_400;
act(() => resizeObservers.forEach((observer) => observer.trigger()));
renderChatArea(root, [...history, createMessage("reply-1")], true);
expect(scrollNode.scrollTop).toBe(100);
});
});
function renderChatArea(
root: Root,
messages: readonly UiMessage[],
initialScrollReady: boolean,
): void {
act(() => {
root.render(
<ChatArea
messages={messages}
isReplyingAI={false}
initialScrollReady={initialScrollReady}
/>,
);
});
}
function createMessage(id: string): UiMessage {
return {
id,
content: `Message ${id}`,
isFromAI: true,
date: "2026-07-15",
};
}
function getScrollNode(container: HTMLElement): HTMLElement {
const node = container.querySelector<HTMLElement>(
'[aria-label="Chat messages"]',
);
if (!node) throw new Error("Chat scroll container was not rendered");
return node;
}
@@ -9,9 +9,13 @@
calc(var(--chat-inline-padding, 20px) + var(--app-safe-right, 0px))
var(--spacing-lg, 16px)
calc(var(--chat-inline-padding, 20px) + var(--app-safe-left, 0px));
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-3, 12px);
min-width: 0;
}
.dateHeader {
+53 -18
View File
@@ -43,6 +43,7 @@ const ReplyingAnimation = lazy(() =>
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
initialScrollReady?: boolean;
isUnlockingMessage?: boolean;
unlockingMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
@@ -54,6 +55,7 @@ export interface ChatAreaProps {
export function ChatArea({
messages,
isReplyingAI,
initialScrollReady = true,
isUnlockingMessage,
unlockingMessageId,
onUnlockPrivateMessage,
@@ -62,36 +64,60 @@ export function ChatArea({
onOpenImage,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const contentRef = useRef<HTMLDivElement>(null);
const initialScrollSettledRef = useRef(false);
const wasNearBottomRef = useRef(true);
const shouldStickToBottomRef = useRef(true);
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
useLayoutEffect(() => {
if (initialScrollSettledRef.current || messages.length === 0) return;
if (!initialScrollReady) {
initialScrollSettledRef.current = false;
return;
}
const scrollNode = scrollRef.current;
if (!scrollNode) return;
if (!initialScrollSettledRef.current) {
initialScrollSettledRef.current = true;
prevLengthRef.current = messages.length;
scrollNode.scrollTop = scrollNode.scrollHeight;
wasNearBottomRef.current = true;
}, [messages.length]);
shouldStickToBottomRef.current = true;
}
// 新消息 → 滚到底部
useEffect(() => {
if (messages.length !== prevLengthRef.current) {
const scrollNode = scrollRef.current;
if (!shouldStickToBottomRef.current) return;
if (scrollNode && wasNearBottomRef.current) {
scrollNode.scrollTo({
top: scrollNode.scrollHeight,
behavior: "smooth",
scrollToBottom(scrollNode);
const frameId = requestAnimationFrame(() => {
if (
scrollRef.current === scrollNode &&
shouldStickToBottomRef.current
) {
scrollToBottom(scrollNode);
}
});
return () => cancelAnimationFrame(frameId);
}, [initialScrollReady, isReplyingAI, messages]);
useEffect(() => {
const scrollNode = scrollRef.current;
const contentNode = contentRef.current;
if (!scrollNode || !contentNode || typeof ResizeObserver === "undefined") {
return;
}
prevLengthRef.current = messages.length;
const observer = new ResizeObserver(() => {
if (
initialScrollSettledRef.current &&
shouldStickToBottomRef.current
) {
scrollToBottom(scrollNode);
}
}, [messages.length]);
});
observer.observe(scrollNode);
observer.observe(contentNode);
return () => observer.disconnect();
}, []);
return (
<main
@@ -99,9 +125,10 @@ export function ChatArea({
className={styles.area}
aria-label="Chat messages"
onScroll={(event) => {
wasNearBottomRef.current = isNearBottom(event.currentTarget);
shouldStickToBottomRef.current = isNearBottom(event.currentTarget);
}}
>
<div ref={contentRef} className={styles.content}>
<AiDisclosureBanner />
{renderMessagesWithDateHeaders(
@@ -120,10 +147,18 @@ export function ChatArea({
<ReplyingAnimation />
</Suspense>
) : null}
</div>
</main>
);
}
function scrollToBottom(scrollNode: HTMLElement): void {
scrollNode.scrollTop = Math.max(
0,
scrollNode.scrollHeight - scrollNode.clientHeight,
);
}
function isNearBottom(scrollNode: HTMLElement): boolean {
const distanceFromBottom =
scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight;