diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx index 70f66040e..51edee952 100644 --- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -415,6 +415,40 @@ describe('assistant-ui streaming renderer', () => { expect(viewport.scrollTop).toBe(420) }) + it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => { + const { container } = render() + + const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement + const viewport = content.parentElement as HTMLDivElement + let scrollHeight = 1_000 + + Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) + Object.defineProperty(viewport, 'scrollHeight', { + configurable: true, + get: () => scrollHeight + }) + + await wait(80) + await wait(0) + + await act(async () => { + fireEvent.wheel(viewport, { deltaY: -120 }) + viewport.scrollTop = 420 + fireEvent.scroll(viewport) + }) + + scrollHeight = 1_200 + + await act(async () => { + for (const observer of resizeObservers) { + observer.trigger(1_200) + } + }) + await wait(0) + + expect(viewport.scrollTop).toBe(420) + }) + it('renders reasoning text without a leading token space', () => { const { container } = render() diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index 2e6bbaf8f..784ed71f3 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -182,6 +182,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v // user-driven upward scroll; re-armed when they reach bottom again. const armedRef = useRef(true) const lastTopRef = useRef(0) + const lastHeightRef = useRef(0) // Counter that tracks how many scroll events we expect to be ours rather // than the user's. `pinToBottom` writes `el.scrollTop`, which fires an // async `scroll` event; without this guard the on-scroll handler can race @@ -206,6 +207,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v programmaticScrollPendingRef.current += 1 el.scrollTop = el.scrollHeight lastTopRef.current = el.scrollTop + lastHeightRef.current = el.scrollHeight }, [scrollerRef]) const jumpToBottom = useCallback(() => { @@ -235,6 +237,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v const disarm = () => { armedRef.current = false + programmaticScrollPendingRef.current = 0 } const onScroll = () => { @@ -250,6 +253,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v if (programmaticScrollPendingRef.current > 0) { programmaticScrollPendingRef.current -= 1 lastTopRef.current = top + lastHeightRef.current = el.scrollHeight // Always re-arm — sticky-bottom should hold through clamp races. armedRef.current = true const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD @@ -258,11 +262,26 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v return } - if (top + 1 < lastTopRef.current) { + // Disarm only when `scrollTop` decreases AND `scrollHeight` did NOT + // grow this frame. A bare `top < lastTopRef.current` check is unsafe: + // when content grows (virtualizer item measurement, streaming token, + // code highlight re-tokenization, composer chip), the browser emits + // an interim `scroll` event whose `scrollTop` is smaller than the + // previous frame's because `scrollHeight` jumped — this fires before + // the rAF-scheduled `pinToBottom` runs, so `programmaticScrollPendingRef` + // is 0. Treating that as a user scroll permanently disarmed sticky-bottom + // and produced the visible at-rest backward jump (#37997). Gating on a + // stable `scrollHeight` keeps real user-driven upward intent — scrollbar + // drag, keyboard PgUp, programmatic scrollIntoView — covered without + // the false positive. Wheel-up and touchmove still disarm via their + // own listeners below. + const heightGrew = el.scrollHeight > lastHeightRef.current + if (!heightGrew && top + 1 < lastTopRef.current) { armedRef.current = false } lastTopRef.current = top + lastHeightRef.current = el.scrollHeight const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD @@ -323,8 +342,9 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v const observer = new ResizeObserver(schedulePin) - observer.observe(el) - + // Observe ONLY the content (firstElementChild), not the scroller `el` + // itself. Resizes of the viewport/scroller (window resize, devtools + // panel toggle) shouldn't trigger a pin — only content growth should. if (el.firstElementChild) { observer.observe(el.firstElementChild) }