From 4232e8547d4e452419b9e8e7650509524b130ecb Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 21:04:36 +0800 Subject: [PATCH] fix: keep thread pinned during keyboard resize --- .../src/components/thread/ThreadViewport.tsx | 43 ++- webui/src/tests/thread-viewport.test.tsx | 309 ++++++++++-------- 2 files changed, 198 insertions(+), 154 deletions(-) diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index f0d395f4..62667d3b 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -51,6 +51,7 @@ const NEAR_TOP_PX = 96; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; const SOFT_KEYBOARD_MIN_INSET_PX = 80; +const KEYBOARD_SCROLL_FRAMES = 18; export const INITIAL_HISTORY_WINDOW = 160; export const HISTORY_WINDOW_INCREMENT = 120; @@ -166,10 +167,20 @@ export const ThreadViewport = forwardRef { + if (remainingFrames <= 0) return; const id = window.requestAnimationFrame(() => { + scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id); if (!force && userReadingHistoryRef.current) return; scrollToBottomNow(smooth); + scheduleNext(remainingFrames - 1); }); scrollFrameIdsRef.current.push(id); - } + }; + run(); + scheduleNext(frames - 1); }, [cancelScheduledBottomScroll, scrollToBottomNow], ); @@ -253,10 +268,18 @@ export const ThreadViewport = forwardRef { const updateKeyboardInset = () => { - const next = readSoftKeyboardInsetBottom(scrollRef.current); + const scrollEl = scrollRef.current; + const next = readSoftKeyboardInsetBottom(scrollEl); + const active = document.activeElement; + const composerFocused = + hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active)); setKeyboardInsetBottom((current) => Math.abs(current - next) < 1 ? current : next, ); + if (composerFocused) { + userReadingHistoryRef.current = false; + scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true }); + } }; updateKeyboardInset(); const viewport = window.visualViewport; @@ -272,7 +295,7 @@ export const ThreadViewport = forwardRef { if (!atBottom) return; @@ -284,7 +307,7 @@ export const ThreadViewport = forwardRef { if (keyboardInsetBottom > 0) { userReadingHistoryRef.current = false; - scrollToBottom(false, 8, { force: true }); + scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true }); return; } if (userReadingHistoryRef.current) return; @@ -299,7 +322,7 @@ export const ThreadViewport = forwardRef { }); 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; + const scrollTo = vi.fn(); + 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, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + scrollTo.mockClear(); + + const input = screen.getByLabelText("Message input"); + act(() => { + input.focus(); + fireEvent.focusIn(input); + }); + + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 1800, + behavior: "auto", + }), + ); + }); + + it("scrolls recent messages into view when the focused composer resizes the visual viewport without an inset", async () => { + const visualViewport = stubVisualViewport({ innerHeight: 500, height: 500 }); + const scrollTo = vi.fn(); try { const { container } = render( @@ -238,28 +273,30 @@ describe("ThreadViewport", () => { Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 2400 }, clientHeight: { configurable: true, value: 600 }, - scrollTop: { configurable: true, value: 0 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, }); - act(() => { - scroller.dispatchEvent(new Event("scroll")); - }); - scrollIntoView.mockClear(); - const input = screen.getByLabelText("Message input"); + Object.defineProperty(document, "activeElement", { + configurable: true, + get: () => input, + }); + act(() => { - input.focus(); - fireEvent.focusIn(input); + visualViewport.viewport.dispatchEvent(new Event("resize")); }); await waitFor(() => - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "end", + expect(scrollTo).toHaveBeenCalledWith({ + top: 1800, behavior: "auto", }), ); + expect(scroller).not.toHaveStyle({ bottom: "320px" }); } finally { - HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + Reflect.deleteProperty(document, "activeElement"); + visualViewport.restore(); } }); @@ -589,148 +626,132 @@ describe("ThreadViewport", () => { }); it("resets to the bottom when opening a different conversation", async () => { - const scrollIntoView = vi.fn(); - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; - HTMLElement.prototype.scrollIntoView = scrollIntoView; + const scrollTo = vi.fn(); + const { container, rerender } = render( + } + conversationKey="chat-a" + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + scrollTo.mockClear(); - try { - const { container, rerender } = render( - } - conversationKey="chat-a" - />, - ); - 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(); + rerender( + } + conversationKey="chat-b" + />, + ); - rerender( - } - conversationKey="chat-b" - />, - ); - - await waitFor(() => - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "end", - behavior: "auto", - }), - ); - } finally { - HTMLElement.prototype.scrollIntoView = originalScrollIntoView; - } + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 1800, + behavior: "auto", + }), + ); }); it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => { - const scrollIntoView = vi.fn(); - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; - HTMLElement.prototype.scrollIntoView = scrollIntoView; + const scrollTo = vi.fn(); + const { container, rerender } = render( + } + conversationKey={null} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 0 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + scrollTo.mockClear(); - try { - const { container, rerender } = render( - } - conversationKey={null} - />, - ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; - Object.defineProperty(scroller, "scrollHeight", { - configurable: true, - value: 0, - }); - scrollIntoView.mockClear(); + rerender( + } + conversationKey="chat-a" + />, + ); + expect(scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: "auto", + }); - rerender( - } - conversationKey="chat-a" - />, - ); - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "end", + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2400, + }); + scrollTo.mockClear(); + + rerender( + } + conversationKey="chat-a" + />, + ); + + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 1800, behavior: "auto", - }); - - Object.defineProperty(scroller, "scrollHeight", { - configurable: true, - value: 2400, - }); - scrollIntoView.mockClear(); - - rerender( - } - conversationKey="chat-a" - />, - ); - - await waitFor(() => - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "end", - behavior: "auto", - }), - ); - } finally { - HTMLElement.prototype.scrollIntoView = originalScrollIntoView; - } + }), + ); }); it("scrolls to the bottom when explicitly signalled after send", async () => { - const scrollIntoView = vi.fn(); - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; - HTMLElement.prototype.scrollIntoView = scrollIntoView; + const scrollTo = vi.fn(); + const { container, rerender } = render( + } + scrollToBottomSignal={0} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + scrollTo.mockClear(); - try { - const { container, rerender } = render( - } - scrollToBottomSignal={0} - />, - ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; - Object.defineProperty(scroller, "scrollHeight", { - configurable: true, - value: 2400, - }); - scrollIntoView.mockClear(); + rerender( + } + scrollToBottomSignal={1} + />, + ); - rerender( - } - scrollToBottomSignal={1} - />, - ); - - await waitFor(() => - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "end", - behavior: "auto", - }), - ); - } finally { - HTMLElement.prototype.scrollIntoView = originalScrollIntoView; - } + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 1800, + behavior: "auto", + }), + ); }); });