Skip to content

Fix slow scrolled tab strip interactions - #147

Open
azooz2003-bit wants to merge 3 commits into
mainfrom
issue-browser-tab-strip-scroll-perf
Open

azooz2003-bit wants to merge 3 commits into
mainfrom
issue-browser-tab-strip-scroll-perf

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 13, 2026 •

Copy link
Copy Markdown

Summary

  • Keep tab bar geometry in scroll-content coordinates so scroll offset changes do not invalidate every tab frame.
  • Translate drag, hover, hit-test, reorder, and cursor points through the current NSScrollView offset.
  • Add regression coverage for scrolled trailing chrome and hit regions.

Validation

  • swift test --filter BonsplitTests/testTabBarTrailingEmptyChromeUsesScrolledContentCoordinates
  • swift test --filter BonsplitTests/testTabBarDragZoneCursorUsesScrolledContentCoordinates
  • swift test

Downstream cmux PR: manaflow-ai/cmux#6053


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


Summary by cubic

Fix sluggish tab strip interactions while scrolled by keeping tab geometry in content-space and translating interactions by the current scroll offset. Addresses the tab strip scroll performance issue and fixes scrolled hit regions.

  • Bug Fixes
    • Keep tab frames in content-space to avoid per-scroll frame invalidation.
    • Translate drag, hover, hit-test, reorder, and cursor math by the scroll offset for accurate behavior while scrolled.
    • Add TabBarScrollAffordances and ignore the trailing drop zone when computing left/right scroll affordances.
    • Move the selected tab indicator and bottom separator into content-space; fix trailing empty chrome hit region and minimal-mode cursor rects; add regression tests.

Written for commit 674339b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved mouse interaction accuracy and hit-testing for horizontally scrolled tabs.
    • Fixed drag-and-drop and manual tab reordering when content is scrolled.
    • Corrected cursor behavior and drag-zone detection in scrolled tab bar areas.
  • Style

    • Added visual indicator to highlight the currently selected tab.

@coderabbitai

coderabbitai Bot commented Jun 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Tab-bar scroll affordance plumbing and coordinate space are refactored to support horizontal scrolling. A new TabBarScrollAffordances model and bridge methods track scroll availability. Tab frames migrate from bar-space to content-space measurement. Drag/drop hit-testing shifts points and bounds by current scroll offset via scrollOffsetProvider closures. A selected-tab visual indicator is added. Tests validate scroll-aware coordinate handling in hit regions and affordance logic.

Changes

Tab Bar Scroll-Aware Hit-Testing and Frame Coordinate Refactoring

Layer / File(s) Summary
Scroll Affordances Model and Bridge Methods
Sources/Bonsplit/Internal/Views/TabBarView.swift
TabBarScrollAffordances type introduced carrying left/right scroll availability. TabBarScrollViewBridge now exposes currentScrollOffset() and shouldForceResetToLeading(). TabBarStyling scroll-affordance helpers refactored to compute affordances based on container overflow and trailing drop-zone width. TabBarView state updated to track tabScrollAffordances instead of raw scroll offset.
Tab Frame Coordinate Space Migration
Sources/Bonsplit/Internal/Views/TabBarView.swift
Tab frames measured in content-space (tabFramesInContent) instead of bar-space. Coordinate space name updated from "tabBar" to tabContentCoordinateSpaceName. Selected-tab frame computation, tab-strip separator overlay, split-button separator rendering, and scroll-content layout logic all propagate content-space frames. tabBarBottomSeparator() refactored to derive separator gaps from content-space frames.
Drag/Drop and Manual Reorder Hit-Testing with Scroll Offset
Sources/Bonsplit/Internal/Views/TabBarView.swift
Drag-zone, background, and manual-reorder tracking views accept scrollOffsetProvider closure backed by ScrollViewBridge.currentScrollOffset(). Hit-testing logic shifts points and bounds by scroll offset so mouse interactions align with scrolled content. Manual reorder drop-target calculation uses scrolled contentPoint to locate tabs. Background hit-testing and drag-zone cursor/capture logic incorporate scroll offset for trailing empty-chrome regions.
Selected Tab Visual Indicator
Sources/Bonsplit/Internal/Views/TabItemView.swift
Adds a colored Rectangle overlay indicator at top-leading edge when tab is selected, with sizing and inset from TabBarMetrics. Disables animations for overlay update and selection-state changes via .animation(nil, value: isSelected).
Test Coverage for Scroll-Aware Coordinate Handling
Tests/BonsplitTests/BonsplitTests.swift
Unit tests validate scroll affordances ignore trailing drop zones and tab-lane hit testing compares content-space frames against scrolled content-space visible bounds (rejecting viewport-space bounds). Regression tests verify trailing-chrome hit-testing and minimal-mode cursor rects use scrolled content coordinates via scrollOffsetProvider, ensuring visible scrolled tabs aren't covered while empty chrome captures clicks correctly.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/bonsplit#127: Both PRs update TabBarView's trailing empty-chrome hit-testing and cursor-rect geometry to account for scroll-offset-aware interactions in the drag-zone logic.
  • manaflow-ai/bonsplit#125: Both PRs modify TabItemView selection-chrome indicator rendering and suppress implicit SwiftUI animations for selection state changes.
  • manaflow-ai/bonsplit#123: Both PRs change tab-bar layout logic around split-button/action lane using content geometry and propagate content-space tab frame computations into separator rendering.

Poem

🐰 Scrolling tabs with grace and care,
Content frames float through the air,
Hit-tests shift by offset true,
Mouse and drag now line up too,
Indicators shine so bright and small,
A refactored bar to serve them all! 🎨

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Fix slow scrolled tab strip interactions' accurately summarizes the main objective: addressing performance issues with tab strip interactions when scrolled.
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.

✏️ 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 issue-browser-tab-strip-scroll-perf

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 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes incorrect tab-bar interactions when the tab strip is horizontally scrolled by keeping all tab geometry in scroll-content coordinates and injecting a scrollOffsetProvider closure into each hit-test, drag, and hover NSView so they can translate viewport-space mouse points into content space on demand.

  • Tab frames are now stored as tabFramesInContent (stable under scrolling), and a new TabBarScrollAffordances struct replaces the raw scrollOffset state, computing left/right scroll-button visibility only when thresholds are crossed.
  • The active-tab indicator is moved from a global masked overlay in TabBarView into a per-tab overlay in TabItemView, matching the new content-coordinate model.
  • Four new unit tests cover scroll affordance edge cases, hit-lane testing with scroll offset, trailing-chrome hit regression, and cursor-rect calculation with a non-zero scroll offset.

Confidence Score: 4/5

The core hit-test and drag-reorder logic is correct and well-tested; the one gap is that the open-hand cursor zone in minimal mode will drift out of position after the user scrolls without triggering a tab-layout change.

All three hit-test paths (drag zone, hover background, manual reorder) correctly translate viewport points into content space using the injected closure, and the new tests confirm the boundary cases. The only unhandled situation is cursor rect staleness in DragNSView after a mid-session scroll: the .openHand rect is recalculated only when hitRegion or isMinimalMode changes, but since tab frames are now content-stable, a pure scroll no longer triggers that path.

Sources/Bonsplit/Internal/Views/TabBarView.swift — specifically the DragNSView cursor rect invalidation path around line 2762.

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Views/TabBarView.swift Core change: replaces viewport-space tab frame tracking with content-space tracking and injects a scrollOffsetProvider into all hit-test, drag, and reorder views. Logic is sound for hit testing; one gap is that DragNSView cursor rects are no longer invalidated when the scroll offset changes (since hitRegion no longer shifts under scrolling).
Sources/Bonsplit/Internal/Views/TabItemView.swift Moves the active-indicator rendering from a global masked overlay into the per-tab view with animations suppressed. Clean and focused change.
Tests/BonsplitTests/BonsplitTests.swift Adds four targeted tests covering tab scroll affordances, hit-lane testing with scroll offset, trailing-chrome hit-test regression, and cursor-rect calculation with scroll offset. All assertions verified consistent with production logic.

Sequence Diagram

sequenceDiagram
    participant SV as NSScrollView
    participant Bridge as TabBarScrollViewBridge
    participant TV as TabBarView (@State)
    participant DZV as DragNSView (DragZone)
    participant DHV as TabBarBackgroundNSView (Hover)
    participant TR as TabBarManualReorderTrackingView

    Note over SV,TV: Content geometry update (scroll or layout)
    SV->>TV: updateTabScrollContent(frame:)
    TV->>Bridge: currentScrollOffset() → CGFloat
    TV->>TV: "tabScrollAffordances = tabScrollAffordances(scrollOffset, contentWidth, containerWidth)"

    Note over TV,DZV: SwiftUI render pass
    TV->>DZV: "updateNSView — hitRegion = .trailingEmptyChrome(tabFramesInContent, …)"
    TV->>DZV: "scrollOffsetProvider = { bridge.currentScrollOffset() }"
    TV->>DHV: "updateNSView — scrollOffsetProvider = { bridge.currentScrollOffset() }"
    TV->>TR: "updateNSView — scrollOffsetProvider = { bridge.currentScrollOffset() }"

    Note over DZV: Hit test (mouse event)
    DZV->>DZV: "contentPoint(for: viewportPoint) = point + scrollOffset"
    DZV->>DZV: compare against content-space tabFrames

    Note over DHV: Tab item hit check (nonisolated)
    DHV->>DHV: containsBonsplitTabItemHit — localPoint + scrollOffset vs hitBounds + scrollOffset

    Note over TR: Drag reorder (mouse drag)
    TR->>TR: "contentPoint(for: dragPoint) = point + scrollOffset"
    TR->>TR: dropTargetIndex(for: contentPoint, in: pane)
Loading

Reviews (1): Last reviewed commit: "test: cover scrolled tab strip hit regio..." | Re-trigger Greptile

Comment on lines 2762 to 2763
var hitRegion = HitRegion.entireBounds {
didSet { invalidateWindowDragCursorRects() }

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 Cursor rects go stale after tab-bar scroll in minimal mode

resetCursorRects is only triggered by hitRegion.didSet and isMinimalMode.didSet. Before this PR, tabFramesInBar were in viewport coordinates, so every scroll event produced a new set of frames, changed hitRegion, and automatically re-fired didSet → invalidateWindowDragCursorRects(). Now tabFramesInContent are stable under scrolling, hitRegion never changes mid-scroll, and the .openHand cursor rect stays anchored to the position computed at the last tab-layout change. A user who scrolls the tab bar mid-session will see the drag cursor painted over the wrong area (covering a visible tab or missing the actual empty-chrome region) until some unrelated tab event refreshes the frames.

@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: 2

🤖 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/Bonsplit/Internal/Views/TabBarView.swift`:
- Around line 2450-2452: contentPoint(for:) currently only adds the scroll
offset but doesn't convert a point from the tab-bar's coordinate space into the
tab content coordinate space (tabContentCoordinateSpaceName), so
ManualReorderNSView hit-testing is using mixed spaces; fix by converting the
incoming point into the tab-content coordinate space before applying
scrollOffsetProvider() (use the view-to-coordinate-space conversion
corresponding to tabContentCoordinateSpaceName), then add the scroll offset as
before so detection and drop-index math use consistent coordinates; update any
ManualReorderNSView hit-test callers to pass points in the tab-bar coordinate
space into contentPoint(for:) (no other callsite changes needed if they already
do).
- Around line 1647-1650: The fallback separator mask is computed using
selectedTabFrameInContent (content-space) but totalWidth is in viewport-space,
causing drift after horizontal scroll; fix by translating the selected tab frame
into viewport coordinates before calling tabBarLayout.selectedSeparatorGap —
e.g. compute a selectedTabFrameInViewport by subtracting the current horizontal
content scroll/offset (or apply your content->viewport transform) from
selectedTabFrameInContent, then pass that selectedTabFrameInViewport and
totalWidth into tabBarLayout.selectedSeparatorGap so the mask aligns after
scrolling.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: bb07b175-82ba-4d66-8ba4-aef99baae55e

📥 Commits

Reviewing files that changed from the base of the PR and between 5728c21 and 674339b.

📒 Files selected for processing (3)
  • Sources/Bonsplit/Internal/Views/TabBarView.swift
  • Sources/Bonsplit/Internal/Views/TabItemView.swift
  • Tests/BonsplitTests/BonsplitTests.swift

Comment on lines 1647 to 1650
let selectedGap = tabBarLayout.selectedSeparatorGap(
selectedTabFrame: selectedTabFrameInBar,
selectedTabFrame: selectedTabFrameInContent,
totalWidth: totalWidth
)

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 | 🟡 Minor | ⚡ Quick win

Fallback separator masking needs viewport translation.

Line 1648 uses selectedTabFrameInContent directly, but this fallback separator is rendered in viewport-space (totalWidth from outer geometry). After horizontal scroll, the mask drifts by the scroll offset.

Suggested patch
-        let selectedGap = tabBarLayout.selectedSeparatorGap(
-            selectedTabFrame: selectedTabFrameInContent,
-            totalWidth: totalWidth
-        )
+        let selectedTabFrameInViewport = selectedTabFrameInContent?.offsetBy(
+            dx: -scrollViewBridge.currentScrollOffset(),
+            dy: 0
+        )
+        let selectedGap = tabBarLayout.selectedSeparatorGap(
+            selectedTabFrame: selectedTabFrameInViewport,
+            totalWidth: totalWidth
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let selectedGap = tabBarLayout.selectedSeparatorGap(
selectedTabFrame: selectedTabFrameInBar,
selectedTabFrame: selectedTabFrameInContent,
totalWidth: totalWidth
)
let selectedTabFrameInViewport = selectedTabFrameInContent?.offsetBy(
dx: -scrollViewBridge.currentScrollOffset(),
dy: 0
)
let selectedGap = tabBarLayout.selectedSeparatorGap(
selectedTabFrame: selectedTabFrameInViewport,
totalWidth: totalWidth
)
🤖 Prompt for 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.

In `@Sources/Bonsplit/Internal/Views/TabBarView.swift` around lines 1647 - 1650,
The fallback separator mask is computed using selectedTabFrameInContent
(content-space) but totalWidth is in viewport-space, causing drift after
horizontal scroll; fix by translating the selected tab frame into viewport
coordinates before calling tabBarLayout.selectedSeparatorGap — e.g. compute a
selectedTabFrameInViewport by subtracting the current horizontal content
scroll/offset (or apply your content->viewport transform) from
selectedTabFrameInContent, then pass that selectedTabFrameInViewport and
totalWidth into tabBarLayout.selectedSeparatorGap so the mask aligns after
scrolling.

Comment on lines +2450 to +2452
private func contentPoint(for point: NSPoint) -> NSPoint {
NSPoint(x: point.x + max(0, scrollOffsetProvider?() ?? 0), y: point.y)
}

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 | 🏗️ Heavy lift

Manual reorder hit-testing still mixes coordinate spaces.

Line 1380 stores tab frames in tabContentCoordinateSpaceName, but Line 2451 only adds scroll offset to local points. ManualReorderNSView points are in tab-bar view coordinates, so a non-zero scroll viewport origin (for example, when a leading inset is present) shifts source-tab detection and drop-index thresholds.

🤖 Prompt for 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.

In `@Sources/Bonsplit/Internal/Views/TabBarView.swift` around lines 2450 - 2452,
contentPoint(for:) currently only adds the scroll offset but doesn't convert a
point from the tab-bar's coordinate space into the tab content coordinate space
(tabContentCoordinateSpaceName), so ManualReorderNSView hit-testing is using
mixed spaces; fix by converting the incoming point into the tab-content
coordinate space before applying scrollOffsetProvider() (use the
view-to-coordinate-space conversion corresponding to
tabContentCoordinateSpaceName), then add the scroll offset as before so
detection and drop-index math use consistent coordinates; update any
ManualReorderNSView hit-test callers to pass points in the tab-bar coordinate
space into contentPoint(for:) (no other callsite changes needed if they already
do).

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