NousResearch/hermes-agent

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

1open issue
0open PRs
17items grouped
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.

Most 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

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #38221 fix(desktop): stop chat scroll bounce — at-rest backward jump + wheel-up snap-back

    merged to main

    files: apps/desktop/src/components/assistant-ui/streaming.test.tsx, apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx

    Show 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.00 means 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 Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#38221
Closed issues
11
Attempts
4 PRs closed without merging
Fix link checked
3 Jun 2026

For your agent

The same facts, typed.