Skip to content

Re-assert sidebar overlay scrollers when the preferred scroller style changes (#3241 sidebar scope) - #5955

Merged
austinywang merged 5 commits into
mainfrom
fix-sidebar-scrollbar-display-change
Jun 12, 2026
Merged

austinywang merged 5 commits into
mainfrom
fix-sidebar-scrollbar-display-change

Conversation

@austinywang

@austinywang austinywang commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Sidebar scope of the #3241 reopen ("Verify the merged sidebar fix (#4767) actually holds in legacy mode — AppKit can re-tile/override on style changes"). The browser-pane / process-wide scope is handled in the parallel PR #5847, whose description points at this PR for the sidebar scroller config.

Mechanism

AppKit resets every NSScrollView's scrollerStyle to the new system preference when NSScroller.preferredScrollerStyleDidChangeNotification fires — a mouse is connected/disconnected, or System Settings → Appearance → "Show scroll bars" changes. That clobbers the sidebar's forced .overlay configuration (from #4767 / SidebarScrollViewConfigurator) with a legacy, space-reserving scrollbar track. The config is only re-applied when some unrelated SwiftUI re-render happens to re-run SidebarScrollViewResolver — with an idle sidebar the legacy track persists indefinitely.

The terminal already handles exactly this notification (handlePreferredScrollerStyleChange in GhosttyTerminalView.swift, the #2997-class fix); the sidebar did not.

Fix

SidebarScrollViewResolverView (moved unchanged from ContentView.swift into SidebarScrollViewConfigurator.swift for testability) now observes NSScroller.preferredScrollerStyleDidChangeNotification and re-resolves. The existing async main hop in resolveScrollView() guarantees the re-apply runs after AppKit's own synchronous per-scroll-view reset, regardless of observer registration order.

  • Both sidebar resolver call sites share this one path (shared-behavior policy); dragAutoScrollController.attach re-invocation is an already-established repeated path and idempotent.
  • The configurator's guarded writes keep the re-apply a no-op when nothing changed, preserving the knob-fade contract from the sidebar scrollbar always visible — should only appear when content overflows #3241 follow-up (existing reapplyToConfiguredScrollViewWritesNothing test still enforces it).

Regression test (two-commit red/green)

  1. Commit 1 (red): relocates the resolver view (no behavior change) and adds resolverReassertsOverlayConfigurationAfterPreferredScrollerStyleChange, which simulates AppKit's reset (scrollerStyle = .legacy), posts the notification, and expects a re-resolve + .overlay re-asserted. Fails without the observer — the re-resolve-count assertion makes the red deterministic even on runners whose preferred style is already overlay.
  2. Commit 2 (green): adds the observer.

Test lives in the already-wired cmuxTests/SidebarScrollViewConfiguratorTests.swift (no pbxproj changes needed).

Localization audit

No user-facing strings added or changed — AppKit scroller configuration behavior only. Nothing to localize.

Part of #3241.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Keeps the sidebar’s overlay scrollbar style when the system scroller preference changes by re-resolving after AppKit resets scroll views. Fixes the legacy track sticking in idle sidebars (sidebar scope of #3241).

  • Bug Fixes
    • Observe NSScroller.preferredScrollerStyleDidChangeNotification on the main queue in SidebarScrollViewResolverView and trigger a re-resolve.
    • Re-apply the overlay config via a deferred MainActor hop (Task { @MainActor }) so it runs after AppKit’s reset; remains idempotent via guarded writes.
    • Extracted SidebarScrollViewResolverView into SidebarScrollViewResolverView.swift and added a deterministic regression test.

Written for commit 8d60172. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Improved sidebar scrolling to better preserve scrollbar style across system changes.
    • Added a sidebar empty-area view to refine sidebar layout and spacing.
  • Tests

    • Added an automated test to verify scrollbar configuration persists after system preference updates.
  • Refactor

    • Internal sidebar scrolling components reorganized for clearer structure and maintainability.

austinywang and others added 2 commits June 11, 2026 23:10
…red-style change

AppKit resets every NSScrollView's scrollerStyle to the new system
preference when NSScroller.preferredScrollerStyleDidChangeNotification
fires (mouse connect/disconnect, System Settings "Show scroll bars").
That clobbers the sidebar's forced overlay configuration with a legacy,
space-reserving scrollbar until some unrelated SwiftUI re-render happens
to re-run the resolver (#3241 reopen, sidebar scope; browser-pane scope
is PR #5847).

Moves SidebarScrollViewResolverView from ContentView.swift into
SidebarScrollViewConfigurator.swift unchanged (internal visibility) so
the test can exercise it; no behavior change in this commit, so the new
test fails: posting the style-change notification does not re-resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… changes

SidebarScrollViewResolverView now observes
NSScroller.preferredScrollerStyleDidChangeNotification and re-resolves,
re-applying SidebarScrollViewConfigurator's overlay configuration. The
async main hop in resolveScrollView() guarantees the re-apply runs after
AppKit's own synchronous per-scroll-view style reset, regardless of
observer registration order — mirroring the terminal's existing
handlePreferredScrollerStyleChange treatment of the same notification.

Both sidebar resolver call sites share this path, and the configurator's
guarded writes keep the re-apply a no-op when nothing changed, so the
knob-fade contract from the #3241 follow-up is preserved.

Fixes the sidebar scope of the #3241 reopen (browser panes: PR #5847).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 12, 2026 6:41am
cmux-staging Building Building Preview, Comment Jun 12, 2026 6:41am

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4eab1a5a-0ef7-40d5-a7bd-0b95b6c3429c

📥 Commits

Reviewing files that changed from the base of the PR and between 9ef8e6d and 8d60172.

📒 Files selected for processing (2)
  • Sources/SidebarScrollViewResolverView.swift
  • cmuxTests/SidebarScrollViewConfiguratorTests.swift

📝 Walkthrough

Walkthrough

Extracts SidebarScrollViewResolverView into a separate NSView file that observes NSScroller.preferredScrollerStyleDidChangeNotification and re-resolves the enclosing NSScrollView; removes the inline resolver from ContentView; adds the new source to the Xcode project; and adds a test validating notification-driven re-resolution.

Changes

Sidebar Scroller Resolver Module

Layer / File(s) Summary
SidebarScrollViewResolverView implementation
Sources/SidebarScrollViewResolverView.swift
SidebarScrollViewResolverView is an NSView exposing onResolve: ((NSScrollView?) -> Void)?, registers a main-queue observer for NSScroller.preferredScrollerStyleDidChangeNotification, triggers resolveScrollView() on attach, resolves asynchronously to enclosingScrollView, and removes the observer in deinit.
ContentView refactor and project wiring
Sources/ContentView.swift, cmux.xcodeproj/project.pbxproj
Remove the inline resolver from ContentView.swift (keeping SidebarEmptyArea) and add SidebarScrollViewResolverView.swift to the cmux target via PBXBuildFile, PBXFileReference, PBXGroup, and PBXSourcesBuildPhase entries.
Notification-driven re-resolution test
cmuxTests/SidebarScrollViewConfiguratorTests.swift
Add an async test that hosts the resolver in an NSScrollView, simulates a scroller-style reset and posts NSScroller.preferredScrollerStyleDidChangeNotification, then asserts the resolver re-runs and the scroll view’s scroller style is reasserted to .overlay. Includes a bounded yieldUntil helper for deterministic timing.

Sequence Diagram

sequenceDiagram
  participant SwiftUI as SidebarEmptyArea (SwiftUI)
  participant Resolver as SidebarScrollViewResolverView
  participant Notif as NotificationCenter
  participant Scroll as NSScrollView
  SwiftUI->>Resolver: instantiate in view hierarchy
  Resolver->>Notif: register preferredScrollerStyleDidChangeNotification (main queue)
  Resolver->>Scroll: viewDidMoveToSuperview/viewDidMoveToWindow -> resolveScrollView()
  Resolver->>Resolver: async main-actor hop
  Resolver->>Scroll: onResolve(enclosingScrollView)
  Notif->>Resolver: preferredScrollerStyleDidChangeNotification
  Resolver->>Resolver: resolveScrollView() (re-run)
  Resolver->>Scroll: onResolve(re-resolved NSScrollView)
  Resolver->>Notif: deinit removes observer
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hop through views with gentle paws,

I listen when the scroller laws are called,
A little resolver finds the scroll each day,
When styles reset I bounce and re-apply,
The sidebar gleams—overlay here to stay.

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: re-asserting sidebar overlay scrollers when the preferred scroller style changes, with a clear reference to the related issue (#3241 sidebar scope).
Description check ✅ Passed The PR description includes a comprehensive Summary explaining what changed and why, Testing coverage with deterministic regression tests, localization audit, and all required sections filled out with detailed technical explanations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New SidebarScrollViewResolverView stays UI-main-thread: NotificationCenter uses queue .main and resolveScrollView calls onResolve inside Task { @MainActor }. No new Sendable/shared-mutable types or...
Cmux Swift Blocking Runtime ✅ Passed PR adds sidebar scroller re-assert via NotificationCenter observer and a non-blocking DispatchQueue.main.async hop; no semaphores/waits/sleep/timers/polling or DispatchQueue.main.sync in changed pr...
Cmux Expensive Synchronous Load ✅ Passed PR adds SidebarScrollViewResolverView + a scroller-style notification observer; it only defers UI work via Task{ @MainActor } and does not introduce RestorableAgentSessionIndex.load/sysctl loads on...
Cmux Cache Substitution Correctness ✅ Passed PR only changes SwiftUI/NSView sidebar scroller resolver + tests; no diff swaps authoritative reads for cached/opportunistic values in persistence/history/undo/snapshot paths.
Cmux No Hacky Sleeps ✅ Passed No diff present in this environment (git diff empty); PR changes are Swift/AppKit (per file existence) while runtime-no-hacky-sleeps scope is non-Swift TS/JS/shell/build runtime scripts.
Cmux Algorithmic Complexity ✅ Passed New/modified production code (SidebarScrollViewResolverView.swift, SidebarScrollViewConfigurator.swift, SidebarEmptyArea) adds only notification observer + main-actor hop; no loops/full scans over...
Cmux Swift Concurrency ✅ Passed PR doesn’t add forbidden cmux legacy concurrency patterns (no DispatchQueue.global/background queues, Combine state, async-throwable completion handlers, or fire-and-forget Tasks); only AppKit Noti...
Cmux Swift @Concurrent ✅ Passed Checked changed Swift files: no nonisolated async or @concurrent added/used; resolveScrollView() uses Task { @mainactor ... }, and tests are @MainActor with bounded Task.yield() loop—no...
Cmux Swift File And Package Boundaries ✅ Passed New production file Sources/SidebarScrollViewResolverView.swift is 62 lines and is pure AppKit/SwiftUI bridge (notification + main-actor resolution); no mixed domain/network/persistence responsibil...
Cmux Swift Logging ✅ Passed Checked Sources/SidebarScrollViewResolverView.swift, Sources/SidebarScrollViewConfigurator.swift, and modified portions of ContentView.swift for forbidden logging. No print/debugPrint/dump/NSLog (o...
Cmux User-Facing Error Privacy ✅ Passed Reviewed production changes (SidebarScrollViewResolverView.swift, SidebarScrollViewConfigurator.swift, ContentView.swift region) for user-facing errors/alerts/copy and sensitive upstream/provider d...
Cmux Full Internationalization ✅ Passed Checked touched files (incl. new Sources/SidebarScrollViewResolverView.swift): no SwiftUI Text/String(localized:) added there (only fatalError). No i18n catalog/plist/web locale files present/changed.
Cmux Swiftui State Layout ✅ Passed SwiftUI state/layout rules not violated: PR adds an AppKit NSView resolver observer + test; no new @ObservableObject/@Published/@State/@EnvironmentObject layout-measurement or render-time SwiftUI s...
Cmux Architecture Rethink ✅ Passed PASS: Introduces required NSScroller.preferredScrollerStyleDidChangeNotification observer plus deferred @MainActor Task for ordering; no sleeps/locks in prod; test uses bounded yieldUntil only.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Scanned PR-touch files (ContentView.swift, new SidebarScrollViewResolverView.swift, configurator + test, pbxproj): no NSWindow/NSPanel/NSWindowController or `.identifier = NSUserInterfaceItemIdenti...
Cmux Source Artifacts ✅ Passed PR #5955 changes only code/test/config files (Sources/ContentView.swift, Sources/SidebarScrollViewResolverView.swift, cmuxTests/…, cmux.xcodeproj/project.pbxproj); no generated logs/temp/DerivedDat...

✏️ 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 fix-sidebar-scrollbar-display-change

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.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the sidebar scope of issue #3241 by re-asserting the forced overlay scroller configuration whenever NSScroller.preferredScrollerStyleDidChangeNotification fires. AppKit resets every NSScrollView's scrollerStyle to the system preference on that notification, clobbering SidebarScrollViewConfigurator's forced .overlay setting until an unrelated SwiftUI re-render triggers the resolver again.

  • New file SidebarScrollViewResolverView.swift: Moves the previously private inner class out of ContentView.swift for testability, adds an init(frame:) that registers the scroller-style-change observer on queue: .main, and replaces the legacy DispatchQueue.main.async hop with Task { @MainActor }. The deinit properly removes the block-based observer.
  • SidebarScrollViewConfiguratorTests.swift: Adds resolverReassertsOverlayConfigurationAfterPreferredScrollerStyleChange, a deterministic async regression test that simulates AppKit's per-scroll-view reset, posts the notification, and asserts the resolver fires and re-applies .overlay — without any wall-clock sleeps.
  • cmux.xcodeproj/project.pbxproj: Registers the new source file in both the PBXFileReference and PBXBuildFile sections of the app target.

Confidence Score: 5/5

Safe to merge — targeted, well-tested re-assertion of an already-established overlay-scroller invariant using the same notification-observer pattern already present in GhosttyTerminalView.

The observer registration is correct (queue: .main, weak capture, deinit cleanup), the deferred Task { @mainactor } hop preserves the post-AppKit-reset ordering guarantee that the DispatchQueue.main.async it replaced provided, and the guarded writes in SidebarScrollViewConfigurator keep re-application idempotent. The regression test is deterministic and exercises the full notification→re-resolve→re-assert path without wall-clock sleeps. No logic errors, no actor isolation issues, and no production patterns to flag.

No files require special attention.

Important Files Changed

Filename Overview
Sources/SidebarScrollViewResolverView.swift New file: moves SidebarScrollViewResolverView out of ContentView.swift, adds preferredScrollerStyleDidChangeNotification observer (queue: .main, weak self), Task { @mainactor } deferred hop, and correct deinit cleanup. Logic is sound and matches the established GhosttyTerminalView pattern.
Sources/ContentView.swift Removes the private SidebarScrollViewResolverView inner class (now in its own file). No behavior change at the call sites.
cmuxTests/SidebarScrollViewConfiguratorTests.swift Adds a deterministic async regression test for the scroller-style re-assertion. Uses bounded Task.yield() polling (no wall-clock sleeps), correctly assembles the view hierarchy to exercise enclosingScrollView, and verifies both the re-resolve count and the final scrollerStyle.
cmux.xcodeproj/project.pbxproj Adds PBXFileReference and PBXBuildFile entries for SidebarScrollViewResolverView.swift in the app target. Mechanical and correct.

Sequence Diagram

sequenceDiagram
    participant AppKit
    participant NotificationCenter
    participant SidebarScrollViewResolverView
    participant MainActor as Main Actor (Task)
    participant SidebarScrollViewConfigurator

    Note over AppKit: Mouse connect/disconnect or System Settings scroller change
    AppKit->>AppKit: "Reset NSScrollView.scrollerStyle = .legacy (sync, per-scrollview)"
    AppKit->>NotificationCenter: post preferredScrollerStyleDidChangeNotification

    NotificationCenter->>SidebarScrollViewResolverView: observer block (queue: .main)
    SidebarScrollViewResolverView->>MainActor: "Task { @MainActor } enqueued after current sync work"

    Note over MainActor: Runs after AppKit synchronous per-scroll-view reset completes
    MainActor->>SidebarScrollViewResolverView: enclosingScrollView
    SidebarScrollViewResolverView->>SidebarScrollViewConfigurator: apply(to: scrollView)
    SidebarScrollViewConfigurator->>AppKit: "scrollerStyle = .overlay (guarded write, only if changed)"
Loading

Reviews (3): Last reviewed commit: "Express the resolver's deferred hop with..." | Re-trigger Greptile

Comment on lines +45 to +51
scrollerStyleObserver = NotificationCenter.default.addObserver(
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.resolveScrollView()
}

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 queue: nil calls @MainActor-isolated method from background thread

NSScroller.preferredScrollerStyleDidChangeNotification is not guaranteed to be posted on the main thread — the analogous handler in GhosttyTerminalView.swift guards against this explicitly with Thread.isMainThread. With queue: nil, if AppKit fires the notification from a background thread the closure calls self?.resolveScrollView() off the main actor. At runtime the inner DispatchQueue.main.async keeps things correct, but Swift 6 strict concurrency will reject a call to an @MainActor-isolated NSView method from a non-isolated closure. Using queue: .main is semantically equivalent (the DispatchQueue.main.async inside resolveScrollView still provides the post-AppKit-reset ordering guarantee documented in the comment) and is Swift 6-safe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9ef8e6d — the observer now uses queue: .main so the block stays on the main thread for any posting thread. A main-thread post (AppKit's case) still runs it synchronously in place, so the re-apply ordering after AppKit's per-scroll-view reset is unchanged. (The code moved to Sources/SidebarScrollViewResolverView.swift in 21bcda4.)

— Claude Code

austinywang and others added 2 commits June 11, 2026 23:20
Autoreview: the extraction from ContentView.swift should follow the
one-major-type-per-file architecture rule rather than sharing
SidebarScrollViewConfigurator.swift with the configurator enum. No
behavior change; wires the new file into the app target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greptile: queue: nil runs the observer block on the posting thread, so a
background-thread post would call the NSView method off-main. Use .main
— a main-thread post (AppKit's case) still executes the block
synchronously in place, so the re-apply ordering after AppKit's own
per-scroll-view reset is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aziz concurrency policy (autoreview): main-thread hops in new files
should use @mainactor structured concurrency, not DispatchQueue.main
.async — same modernization as the BrowserPanel download path (#5938).
The Task { @mainactor } hop preserves the load-bearing ordering: it is
enqueued synchronously and runs after the current runloop callout, i.e.
after AppKit's own per-scroll-view scroller-style reset.

The test drain switches from a main-queue round trip to a bounded
main-actor yield loop so it makes no cross-mechanism FIFO assumptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 8d601722 Deployed Jun 12, 2026 by vercel[bot]
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