From 1faf0826f6f34c951c25898488031918f5769efe Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 28 Jul 2026 17:09:16 +0800 Subject: [PATCH] fix(webui): keep composer stable while scrolling --- .../src/components/thread/ThreadViewport.tsx | 46 ++++-- webui/src/globals.css | 3 + webui/src/tests/thread-viewport.test.tsx | 136 +++++++++++++----- 3 files changed, 134 insertions(+), 51 deletions(-) diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index ffb6d8c37..601c5dc67 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -174,7 +174,8 @@ export const ThreadViewport = forwardRef(null); + const scrollRef = useRef(null); + const viewportFrameRef = useRef(null); const contentRef = useRef(null); const messageRegionRef = useRef(null); const messageContentRef = useRef(null); @@ -236,6 +237,11 @@ export const ThreadViewport = forwardRef 0; + useLayoutEffect(() => { + scrollRef.current = hasMessages + ? messageRegionRef.current + : viewportFrameRef.current; + }, [hasMessages]); const visibleMessages = useMemo( () => windowMessages(messages, visibleMessageCount), [messages, visibleMessageCount], @@ -360,13 +366,13 @@ export const ThreadViewport = forwardRef { const updateKeyboardInset = () => { - const scrollEl = scrollRef.current; - const next = readSoftKeyboardInsetBottom(scrollEl); + const composerDock = composerDockRef.current; + const next = readSoftKeyboardInsetBottom(composerDock); const active = document.activeElement; const composerFocused = hasMessages && isKeyboardEditableElement(active) - && Boolean(scrollEl?.contains(active)); + && Boolean(composerDock?.contains(active)); setKeyboardInsetBottom((current) => Math.abs(current - next) < 1 ? current : next, ); @@ -609,17 +615,22 @@ export const ThreadViewport = forwardRef
@@ -630,7 +641,7 @@ export const ThreadViewport = forwardRef @@ -638,7 +649,13 @@ export const ThreadViewport = forwardRef
+
) : (
@@ -671,7 +689,7 @@ export const ThreadViewport = forwardRef
-
+ {!hasMessages ?
: null}
(".thread-viewport-scrollbar"); + if (!scroller) throw new Error("thread scrollport not found"); + return scroller; +} + async function renderPromptRailViewport({ scrollTo, }: { @@ -162,7 +168,7 @@ async function renderPromptRailViewport({ />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1800 }, clientHeight: { configurable: true, value: 600 }, @@ -260,6 +266,26 @@ describe("ThreadViewport", () => { expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto"); }); + it("keeps the docked composer outside the message scrollport", () => { + const { container } = render( + composer
} + />, + ); + + const scroller = getScroller(container); + const messageRegion = screen.getByTestId("thread-message-region"); + const composerDock = screen.getByTestId("thread-composer-dock"); + expect(scroller).toBe(messageRegion); + expect(scroller).not.toContainElement(composerDock); + expect(scroller.parentElement).toContainElement(composerDock); + expect(composerDock).toHaveClass("relative"); + expect(composerDock).not.toHaveClass("sticky"); + expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0"); + }); + it("pins a waiting prompt to the exact lower scroll boundary", async () => { const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const threaded: UIMessage[] = [ @@ -276,7 +302,7 @@ describe("ThreadViewport", () => { />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1200 }, clientHeight: { configurable: true, value: 500 }, @@ -323,7 +349,7 @@ describe("ThreadViewport", () => { composer={
composer
} />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1_200 }, clientHeight: { configurable: true, value: 500 }, @@ -367,6 +393,13 @@ describe("ThreadViewport", () => { it("lets the first prompt supersede a pending empty-conversation camera command", async () => { const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const scrollTo = vi.fn(); + const firstPrompt: UIMessage = { + id: "u-first", + role: "user", + content: "first question", + turnId: "turn-first", + createdAt: 1, + }; const { container, rerender } = render( { conversationKey={null} />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1200 }, clientHeight: { configurable: true, value: 500 }, @@ -388,18 +421,32 @@ describe("ThreadViewport", () => { await act(async () => { rerender( composer
} conversationKey="chat-a" + conversationReady={false} + activeTurnId="turn-first" + activeTurnStartedHere + />, + ); + }); + const threadScroller = getScroller(container); + Object.defineProperties(threadScroller, { + scrollHeight: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + jumpTo.mockClear(); + await act(async () => { + rerender( + composer
} + conversationKey="chat-a" + conversationReady activeTurnId="turn-first" activeTurnStartedHere />, @@ -430,7 +477,7 @@ describe("ThreadViewport", () => { />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1904 }, clientHeight: { configurable: true, value: 500 }, @@ -494,7 +541,7 @@ describe("ThreadViewport", () => { composer={
composer
} />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 2_000 }, clientHeight: { configurable: true, value: 500 }, @@ -603,7 +650,7 @@ describe("ThreadViewport", () => { />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1904 }, clientHeight: { configurable: true, value: 500 }, @@ -726,7 +773,7 @@ describe("ThreadViewport", () => { composer={
composer
} />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 2400 }, clientHeight: { configurable: true, value: 600 }, @@ -791,7 +838,7 @@ describe("ThreadViewport", () => { />, ); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1200 }, clientHeight: { configurable: true, value: 500 }, @@ -886,7 +933,7 @@ describe("ThreadViewport", () => { /> ); const { container, rerender } = render(viewport(true)); - const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + const scroller = getScroller(container); Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1_200 }, clientHeight: { configurable: true, value: 500 }, @@ -956,7 +1003,9 @@ describe("ThreadViewport", () => { composer={