From 3aa24e2619363d4b88613838e7a138741af2edf7 Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Wed, 3 Jun 2026 15:57:07 +0800 Subject: [PATCH 1/2] fix(desktop): stop chat scroll backward-jump from content-growth interim scrolls (#37997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread scroll-anchor hook in apps/desktop/src/components/assistant-ui/ thread-virtualizer.tsx was disarming sticky-bottom whenever scrollTop decreased by >1px between scroll events. That check was too eager: when content height grows mid-frame (virtualizer measurement of a newly visible turn, streaming token, Streamdown/Shiki re-tokenization, composer chip toggle), the browser emits an interim 'scroll' event whose scrollTop is smaller than the previous frame's because scrollHeight just jumped. The rAF-scheduled pinToBottom hasn't run yet, so programmaticScrollPendingRef is 0 and the disarm fired. With sticky-bottom disarmed the scroller stuck ~50px above bottom — the visible at-rest backward jump that #37997 describes (and the same root cause as the wheel-up variant in #37527). Fix: - Track scrollHeight per frame (lastHeightRef). Disarm on scrollTop decrease ONLY when scrollHeight did not grow this frame. Real upward user intent (scrollbar drag, keyboard PgUp, programmatic scrollIntoView) still disarms because it moves scrollTop without growing the content. Wheel-up and touchmove continue to disarm via their own listeners. - Stop observing the scroller element itself in the ResizeObserver; only observe its content child. Viewport-only resizes (window resize, devtools panel toggle) no longer trigger spurious pins, matching the intent of the auto-stick-to-bottom behavior. Verified: - apps/desktop `tsc -b` clean. - apps/desktop `vitest run src/components/assistant-ui/streaming.test.tsx` passes (9/9), including the existing wheel-up disarm regression test that asserts scrollTop stays at 420 after a wheel-up + content growth. --- .../assistant-ui/thread-virtualizer.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index 2e6bbaf8f..3ee02e755 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(() => { @@ -250,6 +252,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 +261,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 +341,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) } From c930a49ce9b705debc9444ac56bcfb8d2fc9c3ad Mon Sep 17 00:00:00 2001 From: Fermin Quant <14808645+ferminquant@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:09:24 -0400 Subject: [PATCH 2/2] fix(desktop): honor upward wheel scroll in long threads --- .../assistant-ui/streaming.test.tsx | 34 +++++++++++++++++++ .../assistant-ui/thread-virtualizer.tsx | 1 + 2 files changed, 35 insertions(+) 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 3ee02e755..784ed71f3 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -237,6 +237,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v const disarm = () => { armedRef.current = false + programmaticScrollPendingRef.current = 0 } const onScroll = () => {