Skip to content

Preserve explicit wheel scrollback against passive follow#1965

Merged
lawrencecchen merged 2 commits intomainfrom
task-scrollbar-fix-mainline
Mar 23, 2026
Merged

Preserve explicit wheel scrollback against passive follow#1965
lawrencecchen merged 2 commits intomainfrom
task-scrollbar-fix-mainline

Conversation

@lawrencecchen
Copy link
Copy Markdown
Contributor

@lawrencecchen lawrencecchen commented Mar 23, 2026

Summary

  • add a regression test for wheel scrolling into scrollback followed by a passive bottom packet
  • treat the next scrollbar update after a Ghostty wheel event as explicit user intent
  • stop passive bottom packets from re-enabling follow while the user is reviewing scrollback

Verification

  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination "platform=macOS" -derivedDataPath /tmp/cmux-task-scrollbar-fix-mainline-red3 -only-testing:cmuxTests/GhosttySurfaceOverlayTests/testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket test
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination "platform=macOS" -derivedDataPath /tmp/cmux-task-scrollbar-fix-mainline-green1 -only-testing:cmuxTests/GhosttySurfaceOverlayTests/testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket test
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination "platform=macOS" -derivedDataPath /tmp/cmux-task-scrollbar-fix-mainline-green1 -only-testing:cmuxTests/GhosttySurfaceOverlayTests/testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath test-without-building
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination "platform=macOS" -derivedDataPath /tmp/cmux-task-scrollbar-fix-mainline-green1 -only-testing:cmuxTests/GhosttySurfaceOverlayTests/testInactiveOverlayVisibilityTracksRequestedState test-without-building
  • ./scripts/reload.sh --tag task-scrollbar-fix-mainline

Notes

  • replaces the abandoned attempt at Fix Ghostty viewport sync scroll jitter #1512
  • GhosttySurfaceOverlayTests still has unrelated local failures in testSearchOverlayFocusesSearchFieldAfterDeferredAttach and testSearchOverlayMountDoesNotRetainTerminalSurface when run as a whole class in this environment

Summary by cubic

Fixes scrollback being pulled back to bottom after a wheel scroll. We now treat the next scrollbar update after a wheel event as explicit user intent and keep the viewport pinned.

  • Bug Fixes
    • Post .ghosttyDidReceiveWheelScroll from scrollWheel and track pending explicit wheel intent.
    • On the next scrollbar update, sync once to the user's position and mark userScrolledAwayFromBottom; block passive bottom packets from re-enabling follow.
    • Add regression test testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket with a helper ScrollbarPostingSurfaceView.

Written for commit c1c028e. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed scrollbar synchronization issue where manually scrolling with the mouse wheel would cause unwanted viewport jumps when new terminal content arrives at the bottom.
  • Tests

    • Added test coverage for explicit mouse wheel scroll behavior and scrollback viewport preservation.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 23, 2026 1:13am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

This pull request implements a notification-based coordination mechanism to prevent unwanted viewport auto-scrolling when users interact with scrollback. An explicit mouse wheel scroll now sets a pending state that temporarily overrides the standard scrollbar synchronization logic, ensuring that a subsequent scrollbar update won't yank the viewport away from the user's scrollback position.

Changes

Cohort / File(s) Summary
Scrollbar Synchronization State & Logic
Sources/GhosttyTerminalView.swift
Added .ghosttyDidReceiveWheelScroll notification emission on wheel input. Introduced pendingExplicitWheelScroll and allowExplicitScrollbarSync state flags in GhosttySurfaceScrollView to control when scrollbar updates can reposition the viewport. Replaced the fixed scrollbar.offset + scrollbar.len >= scrollbar.total override with allowExplicitScrollbarSync, limiting the sync window to a single update cycle after explicit wheel input.
Scrollback Pinning Test
cmuxTests/TerminalAndGhosttyTests.swift
Added ScrollbarPostingSurfaceView helper to intercept wheel scroll events and post scrollbar notifications. Introduced makeScrollbar() helper to construct scrollbar values. Added testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket that verifies explicit wheel scrolls maintain their position even when a subsequent "bottom packet" scrollbar update arrives.

Sequence Diagram

sequenceDiagram
    participant User
    participant GhosttyNSView
    participant NotificationCenter
    participant GhosttySurfaceScrollView
    participant Scrollbar

    User->>GhosttyNSView: Mouse wheel scroll
    GhosttyNSView->>NotificationCenter: Post .ghosttyDidReceiveWheelScroll
    NotificationCenter->>GhosttySurfaceScrollView: Notify wheel scroll received
    GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Set pendingExplicitWheelScroll=true
    GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Update viewport position
    
    Scrollbar->>NotificationCenter: Post scrollbar update
    NotificationCenter->>GhosttySurfaceScrollView: Notify scrollbar changed
    GhosttySurfaceScrollView->>GhosttySurfaceScrollView: handleScrollbarUpdate()
    alt pendingExplicitWheelScroll is true
        GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Recompute userScrolledAwayFromBottom
        GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Set allowExplicitScrollbarSync=true
        GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Clear pendingExplicitWheelScroll
    end
    
    GhosttySurfaceScrollView->>GhosttySurfaceScrollView: synchronizeScrollView()
    alt allowExplicitScrollbarSync is true
        GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Apply scrollbar sync override
    end
    GhosttySurfaceScrollView->>GhosttySurfaceScrollView: Set allowExplicitScrollbarSync=false
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly Related PRs

Poem

🐰 A wheel spin, a notification flies,
State flags rise to the skies,
Scrollback pinned, no yanking away,
One update cycle has its say! 📜✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: preventing passive bottom packets from overriding explicit wheel scroll behavior in the scrollback area.
Description check ✅ Passed The description covers the summary and verification sections with sufficient detail, though it lacks the optional demo video and formal review trigger block sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-scrollbar-fix-mainline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c1c028e628

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +7022 to +7024
) { [weak self] _ in
self?.pendingExplicitWheelScroll = true
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset pending wheel intent when no scroll delta is applied

pendingExplicitWheelScroll is set for every wheel event, but it is only cleared later inside handleScrollbarUpdate. If a wheel event does not produce a scrollbar action (for example at scrollback bounds or momentum-only phases), this flag can remain set and cause the next passive scrollbar packet to be treated as explicit, which enables allowExplicitScrollbarSync and can jump the viewport (including back to bottom). That reintroduces the same “yank out of scrollback” behavior this change is meant to prevent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
cmuxTests/TerminalAndGhosttyTests.swift (1)

1594-1603: Consume nextScrollbar after posting to avoid stale replays.

nextScrollbar persists across wheel events, which can accidentally replay old scrollbar packets and make follow-up tests order-dependent.

♻️ Proposed tweak
         override func scrollWheel(with event: NSEvent) {
             super.scrollWheel(with: event)
             guard let nextScrollbar else { return }
+            self.nextScrollbar = nil
             NotificationCenter.default.post(
                 name: .ghosttyDidUpdateScrollbar,
                 object: self,
                 userInfo: [GhosttyNotificationKey.scrollbar: nextScrollbar]
             )
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TerminalAndGhosttyTests.swift` around lines 1594 - 1603,
nextScrollbar is retained between scrollWheel events and can replay stale
scrollbar updates; in the scrollWheel(with:) override (the method referencing
nextScrollbar, GhosttyScrollbar, Notification.Name.ghosttyDidUpdateScrollbar and
GhosttyNotificationKey.scrollbar) post the notification as you already do and
then immediately clear/consume nextScrollbar (set it to nil) so it doesn't
persist for subsequent events, ensuring each wheel event only sends its fresh
scrollbar packet.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 6055-6056: The pendingExplicitWheelScroll boolean is sticky and
armed before verifying surface, causing unrelated packets to be treated as
explicit wheel input; change this to a per-wheel one-shot token/counter (e.g.,
an incrementing wheelToken or small counter) that is only incremented/armed
after the guard let surface succeeds (move the arm to after the surface check
where NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll,
object: self) is called), give the token a bounded lifetime (timeout or
decrement after processing), and update the code paths that currently clear
pendingExplicitWheelScroll on scrollbar packet receipt to instead validate and
clear only the matching token/counter; apply the same token-based fix to the
other occurrences referenced (around lines with pendingExplicitWheelScroll at
6591-6592, 7018-7024, 9078-9082).

---

Nitpick comments:
In `@cmuxTests/TerminalAndGhosttyTests.swift`:
- Around line 1594-1603: nextScrollbar is retained between scrollWheel events
and can replay stale scrollbar updates; in the scrollWheel(with:) override (the
method referencing nextScrollbar, GhosttyScrollbar,
Notification.Name.ghosttyDidUpdateScrollbar and
GhosttyNotificationKey.scrollbar) post the notification as you already do and
then immediately clear/consume nextScrollbar (set it to nil) so it doesn't
persist for subsequent events, ensuring each wheel event only sends its fresh
scrollbar packet.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2442d2e7-7c82-4610-9023-f3377707cc68

📥 Commits

Reviewing files that changed from the base of the PR and between fd279bd and c1c028e.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/TerminalAndGhosttyTests.swift

Comment on lines +6055 to 6056
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self)
guard let surface = surface else { return }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make wheel intent a one-shot token, not a sticky Bool.

pendingExplicitWheelScroll is armed on every wheel event here, but it is only cleared when a scrollbar packet arrives. If that wheel cannot produce a packet — for example Line 6056 returns early because surface is nil, or the user is already at the scrollback boundary — the next unrelated bottom packet is still treated as explicit input and follow gets re-enabled again. A single Bool also collapses multiple wheel events into one pending sync. Please switch this to a per-wheel token/counter with a bounded lifetime, and only arm it after the surface guard succeeds.

Also applies to: 6591-6592, 7018-7024, 9078-9082

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 6055 - 6056, The
pendingExplicitWheelScroll boolean is sticky and armed before verifying surface,
causing unrelated packets to be treated as explicit wheel input; change this to
a per-wheel one-shot token/counter (e.g., an incrementing wheelToken or small
counter) that is only incremented/armed after the guard let surface succeeds
(move the arm to after the surface check where
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object:
self) is called), give the token a bounded lifetime (timeout or decrement after
processing), and update the code paths that currently clear
pendingExplicitWheelScroll on scrollbar packet receipt to instead validate and
clear only the matching token/counter; apply the same token-based fix to the
other occurrences referenced (around lines with pendingExplicitWheelScroll at
6591-6592, 7018-7024, 9078-9082).

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 23, 2026

Greptile Summary

This PR fixes the "doomscroll reversion" bug where performing a mouse-wheel scroll into scrollback would be silently overridden by the next passive terminal output packet that updated the scrollbar at the bottom. The fix introduces a two-flag handshake (pendingExplicitWheelScroll / allowExplicitScrollbarSync) that lets the first scrollbar update after a wheel event act as explicit user intent, while all subsequent passive bottom packets are ignored until the user returns to the bottom on their own.

Key changes:

  • GhosttyNSView.scrollWheel now posts ghosttyDidReceiveWheelScroll immediately, setting pendingExplicitWheelScroll = true in the owning GhosttySurfaceScrollView.
  • handleScrollbarUpdate consumes the pending flag on the next scrollbar packet: it records the new userScrolledAwayFromBottom state and arms allowExplicitScrollbarSync so synchronizeScrollView can apply the position exactly once.
  • After that single sync, allowExplicitScrollbarSync is cleared and subsequent passive bottom packets cannot move the viewport while userScrolledAwayFromBottom remains true.
  • A ScrollbarPostingSurfaceView test helper and testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket regression test are added, following the project's required two-commit red/green structure.
  • The previously #if DEBUG-gated logDragGeometryChange call inside synchronizeScrollView was removed as cleanup.

Confidence Score: 5/5

  • Safe to merge; the two-flag handshake is logically sound, the two-commit red/green regression test confirms correctness, and the only flagged item is a minor edge case with no observable impact in normal usage.
  • The fix correctly targets the race between explicit wheel input and passive terminal output by consuming the pending flag on exactly one scrollbar update. The state transitions are verified end-to-end by the new regression test. The single P2 comment (notification posted before the surface guard) is a theoretical edge case that cannot trigger in normal split/attach workflows today, so it does not block merging.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds pendingExplicitWheelScroll / allowExplicitScrollbarSync two-flag protocol: wheel events set the pending flag; the next scrollbar update syncs the viewport once and then blocks passive bottom packets from overriding the user's position. Debug-only scroll-origin logging was also removed as cleanup.
cmuxTests/TerminalAndGhosttyTests.swift Adds ScrollbarPostingSurfaceView test helper and testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket regression test. The test correctly follows the two-commit red/green structure required by CLAUDE.md and exercises the full passive-bottom-packet scenario end-to-end.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[scrollWheel event fires] --> B[Post ghosttyDidReceiveWheelScroll]
    B --> C[pendingExplicitWheelScroll = true]

    D[ghosttyDidUpdateScrollbar arrives] --> E{pendingExplicitWheelScroll?}
    E -- yes --> F[userScrolledAwayFromBottom =\noffset+len < total]
    F --> G[allowExplicitScrollbarSync = true]
    G --> H[pendingExplicitWheelScroll = false]
    H --> I[synchronizeScrollView]
    E -- no --> I

    I --> J{isLiveScrolling?}
    J -- yes --> K[skip scroll-to-position]
    J -- no --> L{shouldAutoScroll =\nnot userScrolledAwayFromBottom\nOR allowExplicitScrollbarSync}
    L -- true --> M[scroll contentView to targetOrigin]
    L -- false --> N[viewport unchanged\npassive bottom packet blocked]
    M --> O[allowExplicitScrollbarSync = false]
    K --> O
    N --> O
Loading

Reviews (1): Last reviewed commit: "Preserve explicit wheel scrollback again..." | Re-trigger Greptile

Comment on lines +6055 to 6056
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self)
guard let surface = surface else { return }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wheel notification posted before surface guard

The notification fires even when surface is nil, which leaves pendingExplicitWheelScroll = true in the paired GhosttySurfaceScrollView with no subsequent ghosttyDidUpdateScrollbar to drain it. If a surface is later attached (e.g. during a split reattach), its first scrollbar packet will be incorrectly treated as an explicit wheel scroll, potentially snapping the viewport to an unintended position.

Moving the post to just after the guard ensures the flag is only set when there is an actual surface to drive the corresponding scrollbar update:

Suggested change
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self)
guard let surface = surface else { return }
override func scrollWheel(with event: NSEvent) {
guard let surface = surface else { return }
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self)

@lawrencecchen lawrencecchen merged commit da1bfed into main Mar 23, 2026
22 checks passed
@lawrencecchen lawrencecchen deleted the task-scrollbar-fix-mainline branch March 23, 2026 01:51
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…ix-mainline

Preserve explicit wheel scrollback against passive follow
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant