{"repo":"NousResearch/hermes-agent","public_id":"37527","display_label":"Desktop chat auto-scroll and scroll-jump behavior during streaming","summary":"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).","subsystem":"apps/desktop","counts":{"members":17,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":11,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":38221,"merged_at":"2026-06-03T13:05:28+00:00"},"resolution":{"state":"partially_resolved","by":"pr-38221"},"freshness":{"state":"verified","last_verified_at":"2026-06-03T13:05:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08911764705882352},"fix_edges":[{"issue_number":37997,"pr_number":38221,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-03T13:05:28+00:00"}],"members":[{"kind":"issue","number":37527,"title":"[Bug]: Desktop chat mouse-wheel scroll-up snaps back down in long threads","url":null,"state":"closed"},{"kind":"issue","number":37549,"title":"[Bug]: Flickering on the Hermes Desktop app for chat transcripts","url":null,"state":"closed"},{"kind":"issue","number":37811,"title":"Hermes Desktop App: chat view auto-scrolls up / fails to follow latest message","url":null,"state":"closed"},{"kind":"issue","number":37997,"title":"Desktop chat scrollbar jumps backward every few seconds","url":null,"state":"closed"},{"kind":"issue","number":38272,"title":"[Bug]: Desktop Chat window auto-scrolls erratically and fights user input during text streaming","url":null,"state":"closed"},{"kind":"issue","number":44678,"title":"[Feature]: Opt-in `display.follow_stream` — auto-follow the streaming response (yields on scroll-up)","url":null,"state":"closed"},{"kind":"issue","number":44927,"title":"[Feature]: feat: add back streaming auto-follow as an opt-in setting","url":null,"state":"closed"},{"kind":"issue","number":47795,"title":"[Bug]: Desktop chat scroll keeps jumping/bouncing even after streaming stops - reading is impossible","url":null,"state":"closed"},{"kind":"issue","number":53231,"title":"Hermes Desktop auto-scrolls long streamed replies away from the start of the message","url":null,"state":"closed"},{"kind":"issue","number":53426,"title":"[Bug]: Hermes Desktop chat auto-scrolls to bottom while reading older messages","url":null,"state":"closed"},{"kind":"issue","number":60026,"title":"Desktop: option to anchor streamed answers at the top while reading","url":null,"state":"closed"},{"kind":"issue","number":65714,"title":"[Feature]: Desktop — add setting to disable auto-scroll during streaming responses","url":null,"state":"open"},{"kind":"pr","number":37530,"title":"fix(desktop): honor upward wheel scroll in long threads","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":37831,"title":"fix(desktop): honor upward wheel scroll in long threads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c930a49ce","number":0},{"kind":"pr","value":"#38224","number":38224}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":38019,"title":"fix(desktop): stop chat scroll backward-jump from content-growth interim scrolls (#37997)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3aa24e2619","number":0}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38221,"title":"fix(desktop): stop chat scroll bounce — at-rest backward jump + wheel-up snap-back","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/components/assistant-ui/streaming.test.tsx","apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx"],"fix_hunks":[{"file":"apps/desktop/src/components/assistant-ui/streaming.test.tsx","hunks":["@@ -415,6 +415,40 @@ describe('assistant-ui streaming renderer', () => {\n     expect(viewport.scrollTop).toBe(420)\n   })\n \n+  it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {\n+    const { container } = render(<StreamingHarness />)\n+\n+    const content = container.querySelector('[data-slot=\"aui_thread-content\"]') as HTMLDivElement\n+    const viewport = content.parentElement as HTMLDivElement\n+    let scrollHeight = 1_000\n+\n+    Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })\n+    Object.defineProperty(viewport, 'scrollHeight', {\n+      configurable: true,\n+      get: () => scrollHeight\n+    })\n+\n+    await wait(80)\n+    await wait(0)\n+\n+    await act(async () => {\n+      fireEvent.wheel(viewport, { deltaY: -120 })\n+      viewport.scrollTop = 420\n+      fireEvent.scroll(viewport)\n+    })\n+\n+    scrollHeight = 1_200\n+\n+    await act(async () => {\n+      for (const observer of resizeObservers) {\n+        observer.trigger(1_200)\n+      }\n+    })\n+    await wait(0)\n+\n+    expect(viewport.scrollTop).toBe(420)\n+  })\n+\n   it('renders reasoning text without a leading token space', () => {\n     const { container } = render(<ReasoningHarness />)\n "]},{"file":"apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx","hunks":["@@ -182,6 +182,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v\n   // user-driven upward scroll; re-armed when they reach bottom again.\n   const armedRef = useRef(true)\n   const lastTopRef = useRef(0)\n+  const lastHeightRef = useRef(0)\n   // Counter that tracks how many scroll events we expect to be ours rather\n   // than the user's. `pinToBottom` writes `el.scrollTop`, which fires an\n   // async `scroll` event; without this guard the on-scroll handler can race","@@ -206,6 +207,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v\n     programmaticScrollPendingRef.current += 1\n     el.scrollTop = el.scrollHeight\n     lastTopRef.current = el.scrollTop\n+    lastHeightRef.current = el.scrollHeight\n   }, [scrollerRef])\n \n   const jumpToBottom = useCallback(() => {","@@ -235,6 +237,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v\n \n     const disarm = () => {\n       armedRef.current = false\n+      programmaticScrollPendingRef.current = 0\n     }\n \n     const onScroll = () => {","@@ -250,6 +253,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v\n       if (programmaticScrollPendingRef.current > 0) {\n         programmaticScrollPendingRef.current -= 1\n         lastTopRef.current = top\n+        lastHeightRef.current = el.scrollHeight\n         // Always re-arm — sticky-bottom should hold through clamp races.\n         armedRef.current = true\n         const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD","@@ -258,11 +262,26 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v\n         return\n       }\n \n-      if (top + 1 < lastTopRef.current) {\n+      // Disarm only when `scrollTop` decreases AND `scrollHeight` did NOT\n+      // grow this frame. A bare `top < lastTopRef.current` check is unsafe:\n+      // when content grows (virtualizer item measurement, streaming token,\n+      // code highlight re-tokenization, composer chip), the browser emits\n+      // an interim `scroll` event whose `scrollTop` is smaller than the\n+      // previous frame's because `scrollHeight` jumped — this fires before\n+      // the rAF-scheduled `pinToBottom` runs, so `programmaticScrollPendingRef`\n+      // is 0. Treating that as a user scroll permanently disarmed sticky-bottom\n+      // and produced the visible at-rest backward jump (#37997). Gating on a\n+      // stable `scrollHeight` keeps real user-driven upward intent — scrollbar\n+      // drag, keyboard PgUp, programmatic scrollIntoView — covered without\n+      // the false positive. Wheel-up and touchmove still disarm via their\n+      // own listeners below.\n+      const heightGrew = el.scrollHeight > lastHeightRef.current\n+      if (!heightGrew && top + 1 < lastTopRef.current) {\n         armedRef.current = false\n       }\n \n       lastTopRef.current = top\n+      lastHeightRef.current = el.scrollHeight\n \n       const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD\n ","@@ -323,8 +342,9 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v\n \n     const observer = new ResizeObserver(schedulePin)\n \n-    observer.observe(el)\n-\n+    // Observe ONLY the content (firstElementChild), not the scroller `el`\n+    // itself. Resizes of the viewport/scroller (window resize, devtools\n+    // panel toggle) shouldn't trigger a pin — only content growth should.\n     if (el.firstElementChild) {\n       observer.observe(el.firstElementChild)\n     }\n"]}]},{"kind":"pr","number":40428,"title":"fix(desktop): preserve scroll position when switching sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#69554","number":69554},{"kind":"pr","value":"#70478","number":70478}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}