Skip to content

Fix sidebar scrollbar: hide when content fits, fade overlay knob when idle (#3241) - #4767

Merged
austinywang merged 22 commits into
mainfrom
issue-3241-sidebar-scrollbar-always-visible-should
Jun 10, 2026
Merged

austinywang merged 22 commits into
mainfrom
issue-3241-sidebar-scrollbar-always-visible-should

Conversation

@austinywang

@austinywang austinywang commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3241.

Scope

Issue #3241 has two symptoms in the workspace sidebar. This PR fixes both, and reconciles the fix with #5708 (which removed the per-row preference aggregation that fed the #2586 layout livelock).

  1. Phantom scrollbar when content fits (the original report): a scroll track/knob shown even with a single workspace and nothing to scroll.
  2. Overlay scroller never fades (reported live on 2026-06-09, macOS 26.4.1 Tahoe, ~139 workspaces + many active agents): the content genuinely overflows, but the overlay knob is always visible and never fades to invisible the way every other macOS overlay scrollbar does.

Root causes & fixes

1. Phantom scrollbar — finite empty area

The empty drop/tap area below the last row used maxHeight: .infinity, so the scroll document view always reported more height than the viewport → always "overflowing" → scroller always present. Fixed by sizing the empty area to the finite remaining viewport (emptyAreaHeight = max(0, contentMinHeight - rowsHeight)). When the rows fit, content height == viewport, so the overlay scroller stays hidden. The empty area still fills the blank region as a drop/tap target.

2. Never-fades — stable scroller configuration (no churn)

The sidebar reconfigured the backing NSScrollView on every SwiftUI re-render, toggling hasVerticalScroller from a re-render-driven overflow flag (and, in the extension sidebar, from a bounds-change observer). Those re-renders fire constantly while agents update workspace rows (badges, titles, PR sub-rows). Re-adding/restyling the scroller re-shows the overlay knob and restarts its idle fade timer, so under continuous activity the knob never reaches the timeout and never fades. The autohide/overlay flags were already correct — the churn was the defect.

Fix: apply one stable configuration and never toggle it:

scrollView.scrollerStyle = .overlay
scrollView.autohidesScrollers = true
scrollView.hasVerticalScroller = true

AppKit then owns visibility natively — the knob appears on scroll or real overflow and fades when idle, and stays hidden when the content fits (guaranteed by the finite empty area). Forcing .overlay also overrides a system "always show scroll bars" setting, so no permanent track is reserved regardless of the user's preference. The re-render/bounds-driven reconfiguration is removed entirely (contentOverflows, configureSidebarScrollViewFromDocument, and the observesLayoutChanges observer infra are deleted; SidebarScrollViewResolver is back to its upstream shape).

3. Livelock-safe measurement (reconcile with #5708)

#5708 removed SidebarWorkspaceRowIdsPreferenceKey, its per-row .preference emitters, the sidebar-wide onPreferenceChange reduce, and laidOutWorkspaceRowIds — the cmux-owned edge of the #2586 sidebar layout livelock — and made selected-workspace scroll-into-view call scrollTo unconditionally.

This branch had layered a row-layout completeness gate on top of that same removed aggregation. It is dropped entirely. The only measurement kept is a single whole-content GeometryReader in the rows container's .background that emits one SidebarWorkspaceRowsMeasurement, deduped by isEquivalent (0.5pt tolerance). Why this cannot re-feed the #2586 livelock at 139-workspace scale:

  • It is one preference value with a trivial reduce, not a per-row emit + formUnion across every row on every layout pass.
  • The measured value (rows height) is independent of what it feeds (empty-area height / overflow), so it converges in one pass instead of oscillating.
  • Sub-pixel jitter is deduped, so constant agent-driven re-renders do not write @State, so there is no transaction scheduled from inside the preference/layout update cycle.

This is the "single whole-content GeometryReader rather than per-row preferences" option, which is the livelock-safe shape.

Tests

cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift (SidebarWorkspaceScrollLayoutTests):

  • empty area fills only the remaining viewport when rows fit (content == viewport → no scroller, the sidebar scrollbar always visible — should only appear when content overflows #3241 invariant);
  • empty area collapses to 0 when rows already overflow (real scroll);
  • unmeasured rows collapse the area without forcing overflow;
  • measurement ignores stale workspace ids and dedupes sub-pixel jitter (the livelock-safety invariant).

The fade itself is AppKit/NSScrollerImp animation behavior and is not cleanly unit-testable — NSScroller.alphaValue does not reflect the overlay knob's internal fade, and a scripted accessory window can't drive the real fade timer (I verified this with a minimal AppKit harness on the host; the control case didn't fade either, confirming the metric is the wrong seam, not that the fix is wrong). Per the test-quality policy, I did not fabricate a fade test; it is covered by the platform-behavior reasoning above and a real-app visual check.

Verification status

@vercel

vercel Bot commented May 26, 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 10, 2026 5:05am
cmux-staging Building Building Preview, Comment Jun 10, 2026 5:05am

@coderabbitai

coderabbitai Bot commented May 26, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 775c8360-f26e-4c25-bae7-977e99f7f83b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Measures workspace rows height, computes contentMinHeight and emptyAreaHeight with SidebarWorkspaceScrollLayout, detects overflow, configures NSScrollView scroller visibility consistently, adapts SidebarEmptyArea to fixed/minimum heights, and adds tests for completeness and viewport edge cases.

Changes

Sidebar Scrollbar Overflow Detection & Layout

Layer / File(s) Summary
Scroll Layout Metrics Foundation
Sources/WindowChromeMetrics.swift
Added contentMinHeight normalization plus emptyAreaHeight(contentMinHeight:rowsHeight:rowsLayoutCompleteness:), contentOverflows(contentHeight:viewportHeight:tolerance:), and rowsOverflow(rowsHeight:contentMinHeight:rowsLayoutCompleteness:tolerance:) helpers on SidebarWorkspaceScrollLayout.
Row Height Measurement & Preference Key
Sources/ContentView.swift
Added workspaceRowsMeasurement state, SidebarWorkspaceRowsHeightPreferenceKey, and publishing from workspaceRows (measuring total rows height via a background GeometryReader) with reduction to the larger measured height.
Scroll Area Computation & NSScrollView Configuration
Sources/ContentView.swift
Refactored workspaceScrollArea to compute contentMinHeight, emptyAreaHeight, and overflow using measured rows height and layout completeness; added configureSidebarScrollView and configureSidebarScrollViewFromDocument helpers and applied them to workspace and extension sidebar scroll views; clear cached measurements when workspace IDs change.
Empty Area API & Integration
Sources/ContentView.swift
Extended SidebarEmptyArea with expandsVertically and minimumHeight, refactored its hitTarget builder to conditionally constrain height, and updated workspaceScrollContent to accept and apply emptyAreaHeight as a non-expanding minimum.
Layout Metrics Tests
cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift
Added SidebarWorkspaceScrollLayoutTests validating empty-area height and overflow detection across unmeasured, partial, complete, zero-height, fitting, and overflowing scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#4736: Touches SidebarEmptyArea interface and rendering; related to empty-area layout and drop-target behavior.

Poem

🐇 I hopped through rows and took a peek,
Measuring heights for the scrollbar's streak.
When space fits snug, the scroller hides—
A tidy sidebar where balance abides. 🥕

🚥 Pre-merge checks | ✅ 16 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully addresses issue #3241 by implementing empty area height measurement, NSScrollView scroller configuration with autohide, and layout completeness tracking to control scrollbar visibility.
Out of Scope Changes check ✅ Passed All changes—ContentView.swift sidebar layout, WindowChromeMetrics helpers, SidebarEmptyArea configuration, and regression tests—are directly scoped to fix issue #3241 and contain no extraneous modifications.
Cmux Swift Actor Isolation ✅ Passed New SidebarWorkspaceScrollLayout functions marked nonisolated; new value types are isolation-safe; SwiftUI Views on MainActor allowed; no actor isolation violations found.
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking/timing synchronization (semaphores, waits, sleeps, asyncAfter, locks) in production code; changes implement measurement-based layout calculation only.
Cmux No Hacky Sleeps ✅ Passed This check applies only to non-Swift runtime changes (TypeScript, JavaScript, shell, or build/runtime scripts). The PR contains only Swift code, which is explicitly excluded per the rule scope.
Cmux Swift Concurrency ✅ Passed PR introduces no legacy async patterns. Pure calculation helpers in WindowChromeMetrics, SwiftUI/AppKit boundary calls in ContentView sidebar scrollbar fix, and assertion-only tests.
Cmux Swift @Concurrent ✅ Passed PR adds 6 nonisolated synchronous pure helper functions with simple arithmetic/logic operations—no async work, I/O, or heavy computation. Matches allowed case: "nonisolated synchronous pure helpers".
Cmux Swift File And Package Boundaries ✅ Passed 119 lines added to ContentView.swift (under 250-line threshold) for UI/AppKit glue. Reusable layout logic extracted to focused WindowChromeMetrics.swift. Clear extraction path preserved.
Cmux Swift Logging ✅ Passed PR passes swift-logging check: The only NSLog is debug-guarded (#if DEBUG), no ad hoc file/stdout logging, no secrets exposed. New code paths don't require new logs.
Cmux User-Facing Error Privacy ✅ Passed PR contains only UI layout fixes and test code with no user-facing error messages, alerts, or sensitive information exposed.
Cmux Full Internationalization ✅ Passed PR contains only layout/mechanics changes with no new user-facing strings, string catalog modifications, or web UI changes—fully compliant with i18n rules.
Cmux Swiftui State Layout ✅ Passed GeometryReader used only in .background (non-layout-affecting), @State stores simple value measurements appropriately, preference changes handled via proper modifiers.
Cmux Architecture Rethink ✅ Passed PR adds pure helper functions and single state owner without timing repairs, split lifecycle, or duplicate wiring. Clear invariants with comprehensive test coverage validate the fix.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR modifies sidebar layout and NSScrollView configuration within main window, not creating new windows; lint script passed with 24 identifiers checked.
Title check ✅ Passed The title references fixing sidebar scrollbar visibility and overlay knob fading, which aligns with the PR's main objectives of preventing phantom scrollbars and enabling proper fade behavior.
Description check ✅ Passed The PR description provides comprehensive coverage of changes, root causes, fixes, tests, and verification status, fulfilling all required template sections (Summary, Testing, and Checklist items).

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-3241-sidebar-scrollbar-always-visible-should

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.

Comment thread Sources/ContentView.swift
Comment thread Sources/ContentView.swift
Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/ContentView.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes May 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/WindowChromeMetrics.swift`:
- Around line 64-70: The static function emptyAreaHeight in WindowChromeMetrics
is missing a return for the computed value; update nonisolated static func
emptyAreaHeight(contentMinHeight:rowsHeight:) so that after the guard you
explicitly return the computed CGFloat (e.g., add "return" before the max(...)
expression), keeping the existing guard case that returns 0 when rowsHeight is
nil.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d0ce901a-1b67-40dd-94e0-6db6f29c31fe

📥 Commits

Reviewing files that changed from the base of the PR and between 1b22695 and c13e02c.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/WindowChromeMetrics.swift
  • cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift

Comment thread Sources/WindowChromeMetrics.swift
Comment thread Sources/WindowChromeMetrics.swift Outdated
@greptile-apps

greptile-apps Bot commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes two sidebar scrollbar bugs from #3241: a phantom scrollbar when the workspace list fits the viewport (caused by maxHeight: .infinity on the empty drop area always inflating the document view beyond viewport bounds), and an overlay knob that never fades under heavy agent activity (caused by re-configuring NSScrollView on every SwiftUI re-render, which re-tiles the scroller and cancels AppKit's in-flight fade timer).

  • Finite empty area: introduces a GeometryReader-backed SidebarWorkspaceRowsMeasurement preference key that measures the rows container once and passes a finite emptyAreaHeight = max(0, contentMinHeight − rowsHeight) to SidebarEmptyArea, replacing the maxHeight: .infinity that kept the document view always overflowing.
  • Stable scroller config: extracts SidebarScrollViewConfigurator.apply(to:), which uses guard-before-write on each NSScrollView property so re-applies from repeated SwiftUI updates are pure no-ops, letting AppKit own the overlay knob's appear/fade lifecycle without interruption.
  • Livelock-safe measurement: the single whole-content GeometryReader (not per-row preferences) dedupes sub-pixel height jitter via isEquivalent(tolerance: 0.5) so constant agent-driven re-renders do not write @State and cannot re-feed a preference/layout transaction cycle.

Confidence Score: 5/5

Safe to merge — both bug fixes are deterministic, the measurement-to-layout feedback loop converges in one pass by design, and the stable-config no-op-on-reapply invariant is directly unit-tested.

The phantom-scrollbar fix replaces an unbounded empty area with a finite measured remainder, which is straightforward and fully unit-tested at the layout layer. The never-fades fix eliminates re-render-driven NSScrollView property churn via guard-before-write, also directly tested. The single whole-content GeometryReader pattern avoids the per-row preference aggregation that caused the #2586 livelock, and sub-pixel jitter is deduped so @State is not written on every agent-driven re-render. No pre-existing invariants are weakened.

No files require special attention.

Important Files Changed

Filename Overview
Sources/SidebarScrollViewConfigurator.swift New file: guard-before-write pattern on all four NSScrollView overlay-scroller properties ensures re-applies from every SwiftUI re-render are no-ops, preventing scroller re-tiling from cancelling AppKit's in-flight fade timer.
Sources/SidebarWorkspaceRowsMeasurement.swift New file: carries a single whole-content rows-height measurement keyed by workspace-ID list; isEquivalent(tolerance:0.5) deduplication prevents sub-pixel jitter from triggering @State writes and re-feeding the layout cycle.
Sources/SidebarWorkspaceRowsHeightPreferenceKey.swift New file: single-site preference key; reduce keeps the larger-height measurement when two values collide. Today only one posting site exists so the comparison branch is unreachable, but the height-wins tie-break without ID validation is a noted pre-existing concern (already flagged in prior review).
Sources/WindowChromeMetrics.swift Adds emptyAreaHeight(contentMinHeight:rowsHeight:) — clamps to 0 when measurement is absent or zero, otherwise returns the finite remaining viewport; correctly decoupled from what it feeds (rows height), so it converges in one layout pass.
Sources/ContentView.swift Threads emptyAreaHeight through workspaceScrollContent; applies stable scroller config to both workspace and extension sidebars; adds isEquivalent-gated onPreferenceChange handler and visibleWorkspaceRowIds-driven stale-measurement reset; moves renderItems computation into WorkspaceListRenderContext to avoid re-deriving on every workspaceRows call.
Sources/SidebarWorkspaceRenderItem.swift Adds rowWorkspaceId computed property: workspace items return their own ID, group headers return anchorWorkspaceId. Used to build the ordered ID list that keys the rows measurement.
cmuxTests/SidebarScrollViewConfiguratorTests.swift New tests: verifies first apply establishes overlay config and that re-apply to an already-configured scroll view writes zero properties (the no-op-on-reapply invariant that prevents fade-timer cancellation).
cmuxTests/SidebarWorkspaceScrollLayoutTests.swift New tests: covers unmeasured-rows collapse, stale-ID rejection, sub-pixel jitter dedup, rows-fit fill, and overflow-collapse — all the invariants required for both the phantom-scrollbar fix and livelock safety.
cmuxTests/WorkspaceGroupTests.swift Extended existing group render-items test to verify the new rowWorkspaceId mapping: group header rows emit anchorWorkspaceId, workspace rows emit their own ID, in render order.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["workspaceRows (ForEach + padding)"] --> B["GeometryReader in .background measures rows container height"]
    B --> C["SidebarWorkspaceRowsHeightPreferenceKey emits SidebarWorkspaceRowsMeasurement"]
    C --> D{"onPreferenceChange isEquivalent dedup?"}
    D -->|"different (> 0.5pt)"| E["@State workspaceRowsMeasurement updated"]
    D -->|"equivalent (≤ 0.5pt)"| F["skip write — no @State mutation (livelock-safe)"]
    E --> G["emptyAreaHeight = max(0, contentMinHeight − rowsHeight)"]
    H["GeometryReader viewport size.height"] --> G
    G --> I["SidebarEmptyArea minHeight: emptyAreaHeight (finite, not infinity)"]
    I --> J{"rows fit viewport?"}
    J -->|"Yes: rows + emptyArea == viewport"| K["document == viewport — no overflow — scroller hidden"]
    J -->|"No: rows > contentMinHeight"| L["emptyArea = 0 — document overflows — real scroll"]
    M["SidebarScrollViewResolver fires on every SwiftUI re-render"] --> N["SidebarScrollViewConfigurator.apply"]
    N --> O{"each property already correct?"}
    O -->|"no drift"| P["no-op write skipped — fade timer uninterrupted"]
    O -->|"drifted"| Q["minimal write to restore overlay config"]
    P --> R["AppKit owns overlay knob appear / idle fade"]
    Q --> R
Loading

Reviews (13): Last reviewed commit: "merge: resolve ContentView conflict with..." | Re-trigger Greptile

Comment thread Sources/ContentView.swift Outdated
emptyAreaHeight: emptyAreaHeight
)
}
.scrollIndicators(.automatic)

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 .scrollIndicators(.automatic) is inconsistent with the extension sidebar and redundant

.automatic is the SwiftUI default, so adding it here is a no-op at the SwiftUI level. More importantly, the extension sidebar's ScrollView doesn't carry this modifier even though both sidebars then manually override hasVerticalScroller via SidebarScrollViewResolver. The inconsistency makes the intent unclear — if the goal is to suppress SwiftUI's own indicator overlay before the NSScrollView override fires, .never would be the correct choice; if there's no intent, the modifier should be removed. Either way both sidebars should agree.

Suggested change
.scrollIndicators(.automatic)
.scrollIndicators(.never)

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 b59e4a3 by removing the redundant workspace .scrollIndicators(.automatic) modifier so the workspace and extension sidebars both rely on the shared NSScrollView configuration path.

— Claude Code

Comment thread Sources/WindowChromeMetrics.swift Outdated
Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/ContentView.swift
@austinywang
austinywang dismissed coderabbitai[bot]’s stale review May 26, 2026 07:51

Stale CodeRabbit change request from commit c13e02c. The missing return was fixed in 0468a3b, the inline thread is resolved, and current CodeRabbit checks are passing.

…rollbar-always-visible-should

# Conflicts:
#	Sources/ContentView.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 41501bf. Configure here.

Comment thread Sources/WindowChromeMetrics.swift Outdated
austinywang and others added 2 commits June 9, 2026 16:13
#5708 removed SidebarWorkspaceRowIdsPreferenceKey, its per-row
.preference emitters, the sidebar-wide onPreferenceChange aggregation,
and laidOutWorkspaceRowIds — the cmux-owned edge of the #2586 sidebar
layout livelock — and made selected-workspace scroll-into-view call
scrollTo unconditionally.

This branch's overflow fix had layered a row-layout *completeness* gate
on top of that same removed per-row IDs aggregation. Reconcile by
dropping the completeness machinery entirely (it depended on the
deleted key) and keeping only:

- a single whole-content GeometryReader that measures the rows
  container height once (not a per-row preference reduce), deduped by
  SidebarWorkspaceRowsMeasurement.isEquivalent so sub-pixel jitter from
  agent-driven row re-renders never writes @State — so it cannot
  re-feed the #2586 transaction cycle; and
- the finite empty-area height that fixes the original #3241 phantom
  scrollbar.

Also simplify SidebarWorkspaceScrollLayout (drop rowsOverflow,
rowsLayoutCompleteness, and the completeness enum), restore the ghostty
pointer to origin/main, and update the layout tests for the simplified
API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Issue #3241 has two symptoms. The merge reconciliation fixed the first
(phantom scrollbar when content fits, via the finite empty-area height).
This addresses the second, reported live on macOS 26.4.1 with ~139
workspaces and many active agents: the overlay scroller is *always*
visible and never fades, even though the content legitimately overflows.

Root cause (code-level): the sidebar reconfigured the backing
NSScrollView on every SwiftUI re-render — toggling `hasVerticalScroller`
from a re-render-driven overflow flag (and, in the extension sidebar,
from a bounds-change observer). Those re-renders fire constantly while
agents update workspace rows (badges, titles, PR sub-rows). Re-adding /
re-styling the scroller re-shows the overlay knob and restarts its idle
fade timer, so with continuous activity the knob never reaches the
timeout and never fades. The autohide/overlay flags were correct; the
*churn* was the defect.

Fix: apply one stable configuration — `scrollerStyle = .overlay`,
`autohidesScrollers = true`, `hasVerticalScroller = true` — and never
toggle it. AppKit then owns visibility natively: the knob appears on
scroll or real overflow and fades when idle, and stays hidden when the
content fits (guaranteed by the finite empty-area height). Forcing
`.overlay` also overrides a system "always show scroll bars" setting, so
no permanent track is reserved either.

This removes the re-render/bounds-driven reconfiguration entirely:
the `contentOverflows` helper, `configureSidebarScrollViewFromDocument`,
and the `observesLayoutChanges` observer infrastructure (all
PR-introduced) are deleted, restoring SidebarScrollViewResolver to its
upstream shape. The single whole-content rows measurement is kept only
to size the empty area.

Note: the fade itself is AppKit/NSScrollerImp animation behavior and is
not cleanly unit-testable (NSScroller.alphaValue does not reflect the
overlay knob's internal fade), so it is covered by platform-behavior
reasoning + a real-app visual check rather than a fabricated test, per
the test-quality policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@austinywang austinywang changed the title Fix sidebar scrollbar overflow state Fix sidebar scrollbar: hide when content fits, fade overlay knob when idle (#3241) Jun 9, 2026
The reconciliation + stable-scroller redesign nets ~+103 lines in
ContentView.swift and pushes two test files over their tracked budgets.
Trim the scroller-config doc comment and record the accepted growth:
- Sources/ContentView.swift 19052 -> 19155
- cmuxTests/WorkspaceGroupTests.swift 853 -> 860 (rowWorkspaceId coverage)
- add cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift 557
  (now >500 with the layout tests)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g for the scroll-layout tests

Aziz file-organization policy: SidebarWorkspaceRowsMeasurement and
SidebarWorkspaceRowsHeightPreferenceKey move to their own TypeName.swift
files instead of growing WindowChromeMetrics.swift and ContentView.swift.

Aziz test-framework policy: the new SidebarWorkspaceScrollLayoutTests
become a Swift Testing suite in their own file, restoring
SidebarWorkspaceSnapshotRefreshPolicyTests.swift to its pre-PR
XCTest-only state.

No behavior change; focused cmux-unit run passes the new suite 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Empty commit; tree is identical to b43052b.

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

Tree remains identical to b43052b.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang and others added 3 commits June 9, 2026 20:37
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lView properties

Extracts the sidebar overlay-scroller setup into
SidebarScrollViewConfigurator (behavior unchanged: still unconditional
writes) and adds a regression test asserting that re-applying the
configuration to an already-configured scroll view performs zero
property writes. SidebarScrollViewResolver re-resolves on every SwiftUI
update, and a same-value scroller write re-tiles the overlay scrollers
and can cancel an in-flight knob fade without rescheduling it, leaving
the sidebar scrollbar permanently visible.

Red commit: the test fails (4 writes per re-apply) until the no-op
guards land in the follow-up commit.

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

SidebarScrollViewConfigurator.apply(to:) now writes each NSScrollView
property only when the value actually differs. After the first
configuration, the per-SwiftUI-update re-applies from
SidebarScrollViewResolver become pure reads, so AppKit fully owns the
overlay scroller's appear/scroll/fade lifecycle and an in-flight knob
fade can no longer be cancelled by a same-value re-tile — the
mechanism that left the sidebar scrollbar permanently visible during
interactive workspace creation.

Green commit for the regression test added in the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Union of both sides in workspaceScrollContent: main's
emptyAreaTabDropDelegate(renderContext:) signature plus this branch's
finite empty-area sizing (expandsVertically/minimumHeight). Budget TSV
trued up for main-side growth (ContentView, TabManager,
WorkspaceGroupTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@austinywang
austinywang merged commit ca57616 into main Jun 10, 2026
20 checks passed
@austinywang
austinywang deleted the issue-3241-sidebar-scrollbar-always-visible-should branch June 10, 2026 05:34

This branch was successfully deployed

1 active deployment
Preview – cmux — 808530fb Deployed Jun 10, 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.

sidebar scrollbar always visible — should only appear when content overflows

1 participant