Desktop chat auto-scroll and scroll-jump behavior during streaming
Cluster 37527 · apps/desktop. Data as of
1 open item. Fix merged in #38221 on 3 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 17 related items grouped here, 11 issues already closed.
- Fix event
- A fix was merged to main in PR #38221. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #38221. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #38221, then check whether your report is the one still open.
Read #38221Pick up a Lab taskMost members concern the Hermes Desktop chat view scrolling unexpectedly: jumping backward, snapping back after upward wheel scrolls, or auto-scrolling while the user reads. Some are feature requests for opt-in or disableable auto-follow, and a few are related but narrower (flickering, preserving position when switching sessions).
Open issues1
- Issue#65714[Feature]: Desktop — add setting to disable auto-scroll during streaming responses
Open PRs0
Fixes on record
-
#38221 fix(desktop): stop chat scroll bounce — at-rest backward jump + wheel-up snap-back
files:
apps/desktop/src/components/assistant-ui/streaming.test.tsx,apps/desktop/src/components/assistant-ui/thread-virtualizer.tsxShow the diff
-
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(<StreamingHarness />) + + 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(<ReasoningHarness />) -
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) }
-
4 PRs closed without merging
From the close comments: merged via another change 2 · superseded by another PR 1 · other 1
- #37530 fix(desktop): honor upward wheel scroll in long threads · other
-
#37831 fix(desktop): honor upward wheel scroll in long threads · merged via another change →
c930a49,#38224 -
#38019 fix(desktop): stop chat scroll backward-jump from content-growth interim scrolls (#37997) · merged via another change →
3aa24e2 -
#40428 fix(desktop): preserve scroll position when switching sessions · superseded by another PR →
#69554,#70478
Closed issues · 11
- Closed#37527[Bug]: Desktop chat mouse-wheel scroll-up snaps back down in long threads
- Closed#37549[Bug]: Flickering on the Hermes Desktop app for chat transcripts
- Closed#37811Hermes Desktop App: chat view auto-scrolls up / fails to follow latest message
- Closed#37997Desktop chat scrollbar jumps backward every few seconds
- Closed#38272[Bug]: Desktop Chat window auto-scrolls erratically and fights user input during text streaming
- Closed#44678[Feature]: Opt-in `display.follow_stream` — auto-follow the streaming response (yields on scroll-up)
- Closed#44927[Feature]: feat: add back streaming auto-follow as an opt-in setting
- Closed#47795[Bug]: Desktop chat scroll keeps jumping/bouncing even after streaming stops - reading is impossible
- Closed#53231Hermes Desktop auto-scrolls long streamed replies away from the start of the message
- Closed#53426[Bug]: Hermes Desktop chat auto-scrolls to bottom while reading older messages
- Closed#60026Desktop: option to anchor streamed answers at the top while reading
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #38221
- Closed issues
- 11
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 3 Jun 2026