fix(chat): keep sent messages in view

This commit is contained in:
2026-07-15 19:14:36 +08:00
parent 47876484fc
commit cf239a51de
8 changed files with 122 additions and 1 deletions
@@ -145,6 +145,51 @@ describe("ChatArea scrolling", () => {
expect(scrollNode.scrollTop).toBe(100);
});
it("forces the latest user message into view after the keyboard resizes", () => {
scrollHeight = 1_000;
clientHeight = 300;
const history = [createMessage("history-1")];
renderChatArea(root, history, true, { scrollToBottomSignal: 0 });
const scrollNode = getScrollNode(container);
clientHeight = 200;
scrollNode.scrollTop = 700;
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
scrollHeight = 1_200;
renderChatArea(
root,
[...history, createUserMessage("sent-1")],
true,
{ scrollToBottomSignal: 1 },
);
expect(scrollNode.scrollTop).toBe(1_000);
});
it("scrolls below a promotion after the user sends a message", () => {
scrollHeight = 1_000;
clientHeight = 300;
const history = [createMessage("history-1")];
const promotion = createMessage("promotion-1");
renderChatArea(root, [...history, promotion], true, {
scrollToBottomSignal: 0,
});
const scrollNode = getScrollNode(container);
scrollNode.scrollTop = 100;
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
scrollHeight = 1_500;
renderChatArea(
root,
[...history, createUserMessage("sent-1"), promotion],
true,
{ scrollToBottomSignal: 1 },
);
expect(scrollNode.scrollTop).toBe(1_200);
});
it("preserves the visible message after older history is prepended", () => {
scrollHeight = 1_000;
clientHeight = 300;
@@ -184,6 +229,7 @@ function renderChatArea(
canLoadMoreHistory?: boolean;
isLoadingMoreHistory?: boolean;
onLoadMoreHistory?: () => void;
scrollToBottomSignal?: number;
} = {},
): void {
act(() => {
@@ -227,6 +273,15 @@ function createMessage(id: string): UiMessage {
};
}
function createUserMessage(id: string): UiMessage {
return {
id,
content: `Message ${id}`,
isFromAI: false,
date: "2026-07-15",
};
}
function getScrollNode(container: HTMLElement): HTMLElement {
const node = container.querySelector<HTMLElement>(
'[aria-label="Chat messages"]',
+31 -1
View File
@@ -47,6 +47,7 @@ const ReplyingAnimation = lazy(() =>
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
scrollToBottomSignal?: number;
initialScrollReady?: boolean;
canLoadMoreHistory?: boolean;
isLoadingMoreHistory?: boolean;
@@ -62,6 +63,7 @@ export interface ChatAreaProps {
export function ChatArea({
messages,
isReplyingAI,
scrollToBottomSignal = 0,
initialScrollReady = true,
canLoadMoreHistory = false,
isLoadingMoreHistory = false,
@@ -77,6 +79,7 @@ export function ChatArea({
const contentRef = useRef<HTMLDivElement>(null);
const initialScrollSettledRef = useRef(false);
const shouldStickToBottomRef = useRef(true);
const previousScrollToBottomSignalRef = useRef(scrollToBottomSignal);
const loadMoreAnchorRef = useRef<{
scrollHeight: number;
scrollTop: number;
@@ -115,12 +118,33 @@ export function ChatArea({
useLayoutEffect(() => {
if (!initialScrollReady) {
initialScrollSettledRef.current = false;
previousScrollToBottomSignalRef.current = scrollToBottomSignal;
return;
}
const scrollNode = scrollRef.current;
if (!scrollNode) return;
const shouldForceScrollToBottom =
previousScrollToBottomSignalRef.current !== scrollToBottomSignal;
previousScrollToBottomSignalRef.current = scrollToBottomSignal;
if (shouldForceScrollToBottom) {
loadMoreAnchorRef.current = null;
initialScrollSettledRef.current = true;
shouldStickToBottomRef.current = true;
scrollToBottom(scrollNode);
const frameId = requestAnimationFrame(() => {
if (
scrollRef.current === scrollNode &&
shouldStickToBottomRef.current
) {
scrollToBottom(scrollNode);
}
});
return () => cancelAnimationFrame(frameId);
}
if (loadMoreAnchorRef.current && !isLoadingMoreHistory) {
const anchor = loadMoreAnchorRef.current;
loadMoreAnchorRef.current = null;
@@ -149,7 +173,13 @@ export function ChatArea({
});
return () => cancelAnimationFrame(frameId);
}, [initialScrollReady, isLoadingMoreHistory, isReplyingAI, messages]);
}, [
initialScrollReady,
isLoadingMoreHistory,
isReplyingAI,
messages,
scrollToBottomSignal,
]);
useEffect(() => {
const scrollNode = scrollRef.current;