# NousResearch/hermes-agent · Desktop chat auto-scroll and scroll-jump behavior during streaming

## Open now

- 1 issue, 0 PRs open on GitHub.
- #65714: \[Feature\]: Desktop — add setting to disable auto-scroll during streaming responses

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).

## TL;DR

- 17 member(s): 1 open issue(s), 11 closed issue(s), 0 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #38221. Verify it addresses your case.
- **Recommended:** read PR #38221 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-38221`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

- #65714: \[Feature\]: Desktop — add setting to disable auto-scroll during streaming responses

## Closed / duplicate issues

- #37527: \[Bug\]: Desktop chat mouse-wheel scroll-up snaps back down in long threads
- #37549: \[Bug\]: Flickering on the Hermes Desktop app for chat transcripts
- #37811: Hermes Desktop App: chat view auto-scrolls up / fails to follow latest message
- #37997: Desktop chat scrollbar jumps backward every few seconds
- #38272: \[Bug\]: Desktop Chat window auto-scrolls erratically and fights user input during text streaming
- #44678: \[Feature\]: Opt-in \`display.follow_stream\` — auto-follow the streaming response (yields on scroll-up)
- #44927: \[Feature\]: feat: add back streaming auto-follow as an opt-in setting
- #47795: \[Bug\]: Desktop chat scroll keeps jumping/bouncing even after streaming stops - reading is impossible
- #53231: Hermes Desktop auto-scrolls long streamed replies away from the start of the message
- #53426: \[Bug\]: Hermes Desktop chat auto-scrolls to bottom while reading older messages
- #60026: Desktop: option to anchor streamed answers at the top while reading

## Prior & competing PRs

- **#38019: fix(desktop): stop chat scroll backward-jump from content-growth interim scrolls (#37997)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `merged_via`; implication `superseded`; refs `3aa24e2619`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#38221: fix(desktop): stop chat scroll bounce — at-rest backward jump + wheel-up snap-back**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/components/assistant-ui/streaming.test.tsx`, `apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx`
  - fix hunks:
  - `apps/desktop/src/components/assistant-ui/streaming.test.tsx`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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(() => {
    ```

    ```diff
    @@ -235,6 +237,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
     
         const disarm = () => {
           armedRef.current = false
    +      programmaticScrollPendingRef.current = 0
         }
     
         const onScroll = () => {
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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)
         }
    ```
- **#37530: fix(desktop): honor upward wheel scroll in long threads**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#37831: fix(desktop): honor upward wheel scroll in long threads**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `c930a49ce`, `#38224`
- **#40428: fix(desktop): preserve scroll position when switching sessions**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#69554`, `#70478`

## Fix status

- Merged to main in PR #38221 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-03`.
- Subsystem: `apps/desktop`
