Skip to content

bug(streaming): auto-scroll still overrides user scroll position on macOS app — #677 regression / edge case #1360

@nesquena-hermes

Description

@nesquena-hermes

Bug report

Auto-scroll still overrides the user's scroll position during streaming on the macOS app (latest), despite the fixes shipped in #677 (April 18). Nathan reports this is his single biggest day-to-day pain point: while a long response is streaming, scrolling up to read earlier content gets snapped back to the bottom, and you can't read up until the response finishes.

Source: AvidFuturist, April 30 2026.

Verification — #677 fixes are live in master (v0.50.248)

The three fixes from #677 are present in static/ui.js:

  • _scrollPinned flag with 150px unpin threshold — static/ui.js:828–845
  • renderMessages() guards force-scroll behind S.activeStreamIdstatic/ui.js:3401–3405 and the cache path at :2985
  • Scroll-to-bottom button toggled by the scroll listener — static/index.html:210, static/ui.js:838–839

So the obvious regressions are ruled out. The bug is something else.

Reproduction (macOS app, v1.4.0 + WebUI v0.50.248)

  1. Open a long-running session, send a prompt that produces a long response (or kicks off many tool calls)
  2. While streaming, scroll up to re-read earlier content
  3. Observe: the view snaps back to the bottom within ~100–500ms

Candidate root causes (need verification)

A. WKWebView scroll-event timing under momentum

On macOS WKWebView (the Swift Mac app's container), scroll events can fire after momentum has already carried the scrollTop past the listener's snapshot of scrollHeight - scrollTop - clientHeight. The 150px nearBottom check at ui.js:836 then re-asserts _scrollPinned = true while the user is mid-flick, so the next scrollIfPinned() call (from the smd token feed in messages.js) yanks them back down. Browser Safari may behave the same; needs testing.

How to verify: instrument ui.js:835–844 with a console.log of {scrollTop, scrollHeight, clientHeight, nearBottom, _scrollPinned} and see whether _scrollPinned is flipping back to true mid-streaming when the user thinks they're scrolled up.

B. Browser scroll anchoring during tool-card insertions

When the streaming response adds a new tool/activity card to the DOM, the browser may scroll-anchor the viewport relative to the inserted node — pulling the view downward even though no JS scrollIfPinned() fired. The CSS overflow-anchor property on #messages and child cards needs auditing. overflow-anchor: none on #messages is the standard fix.

C. Implicit scrollToBottom() during streaming (queue card / pill paths)

Two scrollToBottom() calls in ui.js are NOT gated on !S.activeStreamId:

  • ui.js:1758 — queue card setTimeout(360ms) after a queue card opens
  • ui.js:1947 — queue pill click handler fallback

If the user has a queued message and Nathan happens to expand the queue card while streaming is active, scrollToBottom() fires unconditionally — both scrolling and re-pinning. This is a corner case but matches "I scrolled up, then it snapped back."

Same pattern in terminal.js:295 and :314 for the terminal panel — fine, separate scroll container.

D. 150px unpin threshold may be too tight on the small macOS app window

The default Mac app window is narrower/shorter than a desktop browser. A single trackpad scroll might not move the user past 150px from the bottom, so the view never actually unpins. Worth raising to ~250px or making the threshold a fraction of clientHeight (e.g. Math.max(150, clientHeight * 0.25)).

Suggested investigation order

  1. Add the diagnostic log from §A to a dev build, reproduce on the Mac app, and capture the scroll-event sequence — this is the highest-leverage data point.
  2. If §A confirms the race, debounce the scroll listener with requestAnimationFrame so the nearBottom check sees post-momentum positions, or require two consecutive nearBottom samples before re-pinning.
  3. Add overflow-anchor: none on #messages (cheap, one-line) and re-test — rules out §B.
  4. Gate the queue-card scrollToBottom() calls (ui.js:1758, 1947) on !S.activeStreamId — defensive.
  5. Make the unpin threshold a fraction of clientHeight (defensive, helps small windows).

Severity

M1. This is the single biggest pain point in daily usage of the app. Marking sprint-candidate for the next release window.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is neededsprint-candidateStrong candidate for next sprintuxUser experience / visual polish

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions