From c5a735549af50ecb305eda57295cdb2ebfc21f36 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 20:51:45 +0800 Subject: [PATCH] fix: scroll thread to bottom on composer focus --- .../src/components/thread/ThreadViewport.tsx | 20 +++++++++ webui/src/tests/thread-viewport.test.tsx | 45 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 598ec246..f0d395f4 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -282,10 +282,30 @@ export const ThreadViewport = forwardRef { + 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; diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index aa4db228..334b75dc 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -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( + } + />, + ); + 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(