fix: scroll thread to bottom on composer focus

This commit is contained in:
chengyongru
2026-06-15 02:54:37 +08:00
committed by Xubin Ren
parent 052e7f1559
commit c5a735549a
2 changed files with 63 additions and 2 deletions
@@ -282,10 +282,30 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}, [messages, atBottom, scrollToBottom]);
useLayoutEffect(() => {
if (keyboardInsetBottom > 0) {
userReadingHistoryRef.current = false;
scrollToBottom(false, 8, { force: true });
return;
}
if (userReadingHistoryRef.current) return;
scrollToBottom(false, 4);
}, [keyboardInsetBottom, scrollToBottom]);
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const onComposerFocus = () => {
const active = document.activeElement;
if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return;
userReadingHistoryRef.current = false;
scrollToBottom(false, 8, { force: true });
};
document.addEventListener("focusin", onComposerFocus);
return () => document.removeEventListener("focusin", onComposerFocus);
}, [hasMessages, scrollToBottom]);
useEffect(() => {
if (scrollToBottomSignal <= 0) return;
userReadingHistoryRef.current = false;
+43 -2
View File
@@ -210,8 +210,7 @@ describe("ThreadViewport", () => {
});
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button.parentElement).toHaveStyle({ bottom: "512px" });
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
act(() => {
visualViewport.viewport.dispatchEvent(new Event("resize"));
@@ -222,6 +221,48 @@ describe("ThreadViewport", () => {
}
});
it("scrolls recent messages into view when the composer receives focus", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollIntoView.mockClear();
const input = screen.getByLabelText("Message input");
act(() => {
input.focus();
fireEvent.focusIn(input);
});
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render(
<ThreadViewport