diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index a621e040..80cf283a 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -127,7 +127,9 @@ export const ThreadViewport = forwardRef(null); const scrollFrameIdsRef = useRef([]); + const programmaticPromptScrollTopRef = useRef(null); const handledLatestPromptSignalRef = useRef(0); + const activeTurnPromptRef = useRef(null); const restoreScrollAfterPrependRef = useRef<{ height: number; top: number } | null>(null); /** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */ @@ -167,6 +169,10 @@ export const ThreadViewport = forwardRef { + programmaticPromptScrollTopRef.current = top; + }, []); + const scrollToBottomNow = useCallback((smooth = false) => { const el = scrollRef.current; const marker = bottomRef.current; @@ -196,6 +202,7 @@ export const ThreadViewport = forwardRef { @@ -245,6 +252,7 @@ export const ThreadViewport = forwardRef 0) { setVisibleMessageCount((count) => @@ -277,6 +285,7 @@ export const ThreadViewport = forwardRef Math.max(count, messages.length - index)); }, [messages]); @@ -360,7 +369,8 @@ export const ThreadViewport = forwardRef { + const promptId = activeTurnPromptRef.current; + if (!promptId || userReadingHistoryRef.current) return; + const promptIndex = messages.findIndex((message) => message.id === promptId); + if (promptIndex < 0) { + activeTurnPromptRef.current = null; + return; + } + const hasAgentOutput = messages + .slice(promptIndex + 1) + .some((message) => message.role !== "user"); + if (!hasAgentOutput) return; + scrollToBottom(false, isStreaming ? 3 : 1); + }, [isStreaming, messages, scrollToBottom]); + useLayoutEffect(() => { const pending = restoreScrollAfterPrependRef.current; if (!pending) return; @@ -438,8 +464,18 @@ export const ThreadViewport = forwardRef { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const near = distance < NEAR_BOTTOM_PX; + const programmaticPromptTop = programmaticPromptScrollTopRef.current; + const programmatic = + programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2; setAtBottom(near); + if (programmatic) { + programmaticPromptScrollTopRef.current = null; + if (near) userReadingHistoryRef.current = false; + return; + } + programmaticPromptScrollTopRef.current = null; userReadingHistoryRef.current = !near; + if (!near) activeTurnPromptRef.current = null; if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll(); }; diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 7c806a9b..301dea8c 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -190,6 +190,160 @@ describe("ThreadViewport", () => { expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start"); }); + it("keeps following active agent output after anchoring the sent prompt", async () => { + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", createdAt: 3 }, + ]; + const answer: UIMessage = { + id: "a2", + role: "assistant", + content: "streaming answer", + createdAt: 4, + }; + const scrollTo = vi.fn(); + const { container, rerender } = render( + composer} + scrollToLatestUserPromptSignal={0} + />, + ); + + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 700 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + const prompt = container.querySelector('[data-user-prompt-id="u2"]'); + expect(prompt).not.toBeNull(); + Object.defineProperty(prompt, "offsetTop", { + configurable: true, + value: 420, + }); + + await act(async () => { + rerender( + composer} + scrollToLatestUserPromptSignal={1} + />, + ); + }); + + expect(scrollTo).toHaveBeenCalledWith({ + top: 404, + behavior: "auto", + }); + + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1800, + }); + scrollTo.mockClear(); + + await act(async () => { + rerender( + composer} + scrollToLatestUserPromptSignal={1} + />, + ); + }); + + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 1300, + behavior: "auto", + }), + ); + }); + + it("does not follow active agent output after the user manually scrolls away", async () => { + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", createdAt: 3 }, + ]; + const answer: UIMessage = { + id: "a2", + role: "assistant", + content: "streaming answer", + createdAt: 4, + }; + const scrollTo = vi.fn(); + const { container, rerender } = render( + composer} + scrollToLatestUserPromptSignal={0} + />, + ); + + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 700 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + const prompt = container.querySelector('[data-user-prompt-id="u2"]'); + expect(prompt).not.toBeNull(); + Object.defineProperty(prompt, "offsetTop", { + configurable: true, + value: 420, + }); + + await act(async () => { + rerender( + composer} + scrollToLatestUserPromptSignal={1} + />, + ); + }); + await act(async () => { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + }); + + scroller.scrollTop = 100; + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 1800, + }); + scrollTo.mockClear(); + + await act(async () => { + rerender( + composer} + scrollToLatestUserPromptSignal={1} + />, + ); + }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + it("keeps the scroll-to-bottom button above a growing composer", () => { const originalResizeObserver = globalThis.ResizeObserver; const resizeObservers: ResizeObserverInstance[] = [];