From f60daf061d3176703a0c7651d95e3a4843826797 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 15 Jul 2026 10:25:08 +0800 Subject: [PATCH] fix(chat): keep message list anchored to bottom --- src/app/chat/chat-screen.tsx | 3 + .../__tests__/chat-area-scroll.test.tsx | 180 ++++++++++++++++++ src/app/chat/components/chat-area.module.css | 4 + src/app/chat/components/chat-area.tsx | 113 +++++++---- 4 files changed, 261 insertions(+), 39 deletions(-) create mode 100644 src/app/chat/components/__tests__/chat-area-scroll.test.tsx diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 3c3b959d..5e456dab 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -229,6 +229,9 @@ export function ChatScreen() { ({ + MessageBubble: ({ content }: { content: string }) =>
{content}
, +})); + +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( + , + ); + }); +} + +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( + '[aria-label="Chat messages"]', + ); + if (!node) throw new Error("Chat scroll container was not rendered"); + return node; +} diff --git a/src/app/chat/components/chat-area.module.css b/src/app/chat/components/chat-area.module.css index eef83486..382c567e 100644 --- a/src/app/chat/components/chat-area.module.css +++ b/src/app/chat/components/chat-area.module.css @@ -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 { diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index ed1f54cf..7444e4a4 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -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(null); - const prevLengthRef = useRef(messages.length); + const contentRef = useRef(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; - initialScrollSettledRef.current = true; - prevLengthRef.current = messages.length; - scrollNode.scrollTop = scrollNode.scrollHeight; - wasNearBottomRef.current = true; - }, [messages.length]); - - // 新消息 → 滚到底部 - useEffect(() => { - if (messages.length !== prevLengthRef.current) { - const scrollNode = scrollRef.current; - - if (scrollNode && wasNearBottomRef.current) { - scrollNode.scrollTo({ - top: scrollNode.scrollHeight, - behavior: "smooth", - }); - } - prevLengthRef.current = messages.length; + if (!initialScrollSettledRef.current) { + initialScrollSettledRef.current = true; + shouldStickToBottomRef.current = true; } - }, [messages.length]); + + if (!shouldStickToBottomRef.current) return; + + 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; + } + + const observer = new ResizeObserver(() => { + if ( + initialScrollSettledRef.current && + shouldStickToBottomRef.current + ) { + scrollToBottom(scrollNode); + } + }); + observer.observe(scrollNode); + observer.observe(contentNode); + + return () => observer.disconnect(); + }, []); return (
{ - wasNearBottomRef.current = isNearBottom(event.currentTarget); + shouldStickToBottomRef.current = isNearBottom(event.currentTarget); }} > - +
+ - {renderMessagesWithDateHeaders( - messages, - getMessageKey, - isUnlockingMessage, - unlockingMessageId, - onUnlockPrivateMessage, - onUnlockVoiceMessage, - onUnlockImageMessage, - onOpenImage, - )} + {renderMessagesWithDateHeaders( + messages, + getMessageKey, + isUnlockingMessage, + unlockingMessageId, + onUnlockPrivateMessage, + onUnlockVoiceMessage, + onUnlockImageMessage, + onOpenImage, + )} - {isReplyingAI ? ( - - - - ) : null} + {isReplyingAI ? ( + + + + ) : null} +
); } +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;