Skip to content

Fix split action lane clipping before empty tab space - #123

Merged
austinywang merged 3 commits into
mainfrom
issue-4110-surface-tab-bar-buttons-clip
May 19, 2026
Merged

austinywang merged 3 commits into
mainfrom
issue-4110-surface-tab-bar-buttons-clip

Conversation

@austinywang

@austinywang austinywang commented May 13, 2026 •

Copy link
Copy Markdown

Summary

  • add a layout regression covering split action buttons using trailing empty tab-bar space before clipping
  • let the split action lane expand into measured trailing whitespace before falling back to the fractional cap

Testing

  • Not run locally; cmux workspace instructions prohibit local test runs.

Summary by cubic

Allow the split action lane to use trailing empty tab bar space before the fractional cap to prevent early clipping of split buttons. Addresses Linear issue 4110.

  • Bug Fixes
    • Use trailing whitespace to raise maximumSplitButtonLaneWidth when it exceeds the fraction cap.
    • Track and pass tabContentWidthExcludingSplitButtonLane from tab scroll geometry into layout.
    • Add tests for whitespace behavior and keep the overflow fixture crowded to avoid false positives.

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

Summary by CodeRabbit

  • Bug Fixes

    • Improved tab bar split-button lane width calculations to prevent overflow and ensure correct viewport positioning.
  • Tests

    • Added test coverage validating split-button lane width constraints and positioning behavior.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 13, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@austinywang has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 56 minutes and 38 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f883a9e8-8f2b-4253-9a60-165733ba09cb

📥 Commits

Reviewing files that changed from the base of the PR and between ff55eda and e1d9954.

📒 Files selected for processing (1)
  • Tests/BonsplitTests/BonsplitTests.swift
📝 Walkthrough

Walkthrough

TabBarLayout now accepts an optional tabContentWidthExcludingSplitButtonLane parameter to refine split-button lane width constraints. TabBarView tracks this measurement from scroll geometry, derives it via a new updateTabScrollContent(frame:) helper, and wires it into TabBarLayout to prevent lane overflow during viewport clipping.

Changes

Split-button lane width constraint refinement

Layer / File(s) Summary
TabBarLayout split-button lane width constraint
Sources/Bonsplit/Internal/Views/TabBarView.swift
TabBarLayout stores tabContentWidthExcludingSplitButtonLane as an optional parameter, clamps it to non-negative values, and uses it to compute trailingWhitespaceBeforeSplitButtonLane from available width (replacing previous calculation).
TabBarView state tracking and scroll geometry measurement
Sources/Bonsplit/Internal/Views/TabBarView.swift
TabBarView introduces tabContentWidthExcludingSplitButtonLane state, adds updateTabScrollContent(frame:) helper that derives the value by subtracting layout trailing inset from scroll frame, and refactors both .onChange and .onAppear observers to call this helper.
Test validation of split-button lane clipping
Tests/BonsplitTests/BonsplitTests.swift
New test validates that TabBarLayout correctly constrains split-button lane width and prevents overflow when tab content width is provided.

Possibly related PRs

  • manaflow-ai/bonsplit#113: Both PRs modify TabBarLayout/TabBarView split-button lane geometry and trailing inset behavior with related unit tests around clipping/overflow.
  • manaflow-ai/bonsplit#105: Both PRs adjust how split-button trailing lane/insets are computed in TabBarView/TabBarLayout, touching the same split-button layout sizing logic.
  • manaflow-ai/bonsplit#97: Both PRs modify TabBarView/TabBarLayout split-button lane sizing and trailing whitespace/inset math for clipping scenarios.

Poem

🐰 A rabbit hops through width constraints,
Measuring tab content with care,
Split buttons stay clipped just right,
No overflow here, no despair!
Tests dance between lanes and insets so fair.


🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: allowing the split action lane to use trailing whitespace before clipping, which is the primary fix implemented in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-4110-surface-tab-bar-buttons-clip

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.

@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 `@Tests/BonsplitTests/BonsplitTests.swift`:
- Around line 425-441: The failing test uses an outdated quarter-cap assumption
for the “outside lane” sample area; update the test/helper used by
testOverflowingSplitButtonsClipToActionLane to compute its sampled outside-lane
region from the same effective visibleSplitButtonLaneWidth that TabBarLayout
uses at runtime (e.g. use layout.visibleSplitButtonLaneWidth or
measuredSplitButtonLaneWidth inputs passed into TabBarLayout) instead of a
hard-coded quarter-cap; ensure any helper that computes the sample rect
references TabBarLayout.visibleSplitButtonLaneWidth so the test reflects the new
lane-width contract.
🪄 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: 58987a5b-015c-45e3-bf07-5923cbb85283

📥 Commits

Reviewing files that changed from the base of the PR and between 0fc0117 and ff55eda.

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

Comment on lines +425 to +441
func testTabBarLayoutUsesTrailingWhitespaceBeforeClippingSplitButtons() {
let measuredWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: 10)
let layout = TabBarLayout(
tabBarHeight: 28,
availableWidth: 930,
tabContentWidthExcludingSplitButtonLane: 300,
splitButtonCount: 10,
splitButtonLaneVisible: true,
reservesSplitButtonLane: true,
measuredSplitButtonLaneWidth: measuredWidth
)

XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 630)
XCTAssertEqual(layout.visibleSplitButtonLaneWidth, measuredWidth)
XCTAssertEqual(layout.trailingTabContentInset, measuredWidth)
XCTAssertFalse(layout.splitButtonLaneOverflowsViewport)
}

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

Update companion rendering assertions for the new lane-width contract.

This new contract allows the action lane to exceed the quarter cap when trailing whitespace exists, but testOverflowingSplitButtonsClipToActionLane still samples using the old quarter-cap assumption and is now failing in CI (Line 1956). Please update that test/helper to derive its “outside lane” sample area from the same effective visibleSplitButtonLaneWidth inputs used at runtime.

🤖 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 `@Tests/BonsplitTests/BonsplitTests.swift` around lines 425 - 441, The failing
test uses an outdated quarter-cap assumption for the “outside lane” sample area;
update the test/helper used by testOverflowingSplitButtonsClipToActionLane to
compute its sampled outside-lane region from the same effective
visibleSplitButtonLaneWidth that TabBarLayout uses at runtime (e.g. use
layout.visibleSplitButtonLaneWidth or measuredSplitButtonLaneWidth inputs passed
into TabBarLayout) instead of a hard-coded quarter-cap; ensure any helper that
computes the sample rect references TabBarLayout.visibleSplitButtonLaneWidth so
the test reflects the new lane-width contract.

@greptile-apps

greptile-apps Bot commented May 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a measurement-based expansion of the split action button lane: instead of always capping it at availableWidth * 0.25, TabBarLayout now measures actual trailing whitespace and allows the lane to grow up to that gap before falling back to the fractional cap.

  • TabBarLayout gains tabContentWidthExcludingSplitButtonLane: CGFloat? and the new trailingWhitespaceBeforeSplitButtonLane property; maximumSplitButtonLaneWidth takes the max of the fraction limit and that trailing whitespace.
  • TabBarView stores the measured tab-content width as optional @State updated in the extracted updateTabScrollContent helper, and a new test validates the regression scenario where 10 split buttons fit in 630 px of trailing whitespace on a 930 px bar.

Confidence Score: 4/5

Safe to merge; the trailing-whitespace expansion logic is correct and self-stabilizing with no risk of infinite layout loops.

The core layout change is small and well-reasoned. The measurement loop in updateTabScrollContent converges within two render passes and all edge cases are handled. The only concerns are two style-level documentation gaps.

Sources/Bonsplit/Internal/Views/TabBarView.swift around updateTabScrollContent warrants a second look to confirm the convergence invariant holds when tab count and container width change in the same render pass.

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Views/TabBarView.swift Adds tabContentWidthExcludingSplitButtonLane state and trailingWhitespaceBeforeSplitButtonLane computed property; the self-referential measurement pattern in updateTabScrollContent is intentional and converges, but subtle enough to warrant a comment.
Tests/BonsplitTests/BonsplitTests.swift Adds a regression test for the trailing-whitespace expansion; the maximumSplitButtonLaneWidth == 630 assertion silently depends on fractionLimit being less than trailingWhitespace, which is not made explicit.

Sequence Diagram

sequenceDiagram
    participant SV as ScrollView (contentGeo)
    participant TV as TabBarView
    participant TBL as TabBarLayout

    Note over TV: Initial state: tabContentWidthExcludingSplitButtonLane = nil
    SV->>TV: onAppear / onChange(frame)
    TV->>TBL: "trailingTabContentInset (nil state -> fractionLimit-based T_old)"
    TV->>TV: "tabContentWidthExcludingSplitButtonLane = frame.width - T_old"
    Note over TV: State update triggers re-render
    TV->>TBL: recompute with new tabContentWidthExcludingSplitButtonLane
    TBL-->>TV: "trailingWhitespaceBeforeSplitButtonLane = availableWidth - tabs"
    TBL-->>TV: "maximumSplitButtonLaneWidth = max(fractionLimit, whitespace)"
    TBL-->>TV: "visibleSplitButtonLaneWidth = min(full, maximum)"
    TBL-->>TV: "trailingTabContentInset = T_new"
    Note over SV: Padding changes -> frame changes
    SV->>TV: "onChange(newFrame) frame.width = tabs + T_new"
    TV->>TV: "tabContentWidthExcludingSplitButtonLane = tabs + T_new - T_new = tabs"
    Note over TV: State unchanged -> converged in 2 passes
Loading

Reviews (1): Last reviewed commit: "test: keep overflow fixture crowded" | Re-trigger Greptile

Comment on lines +1551 to +1555
private func updateTabScrollContent(frame: CGRect) {
scrollOffset = -frame.minX
contentWidth = frame.width
tabContentWidthExcludingSplitButtonLane = max(0, frame.width - tabBarLayout.trailingTabContentInset)
}

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 The self-referential use of tabBarLayout.trailingTabContentInset here is not immediately obvious: tabBarLayout is recomputed from the current (pre-update) tabContentWidthExcludingSplitButtonLane, which matches the inset already baked into frame.width at render time, so the subtraction always yields the bare tab content width and the loop converges. A short comment prevents future maintainers from misreading this as accidental circular state.

Suggested change
private func updateTabScrollContent(frame: CGRect) {
scrollOffset = -frame.minX
contentWidth = frame.width
tabContentWidthExcludingSplitButtonLane = max(0, frame.width - tabBarLayout.trailingTabContentInset)
}
private func updateTabScrollContent(frame: CGRect) {
scrollOffset = -frame.minX
contentWidth = frame.width
// `tabBarLayout.trailingTabContentInset` is computed from the *current* (pre-update)
// `tabContentWidthExcludingSplitButtonLane`, which equals the trailing padding that
// was already baked into `frame.width`. Subtracting it therefore yields the bare tab
// content width. The layout loop converges within two render passes.
tabContentWidthExcludingSplitButtonLane = max(0, frame.width - tabBarLayout.trailingTabContentInset)
}

Comment on lines +425 to +437
func testTabBarLayoutUsesTrailingWhitespaceBeforeClippingSplitButtons() {
let measuredWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: 10)
let layout = TabBarLayout(
tabBarHeight: 28,
availableWidth: 930,
tabContentWidthExcludingSplitButtonLane: 300,
splitButtonCount: 10,
splitButtonLaneVisible: true,
reservesSplitButtonLane: true,
measuredSplitButtonLaneWidth: measuredWidth
)

XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 630)

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 The assertion maximumSplitButtonLaneWidth == 630 is only meaningful when fractionLimit < trailingWhitespace. With availableWidth = 930 and maximumSplitButtonLaneWidthFraction = 0.25, fractionLimit = 232.5, so the whitespace (630) wins. If the fraction constant were ever raised above ~0.677 the assertion would silently start testing the fraction path instead of the whitespace path. A guard assertion on fractionLimit would make the intent explicit.

Suggested change
func testTabBarLayoutUsesTrailingWhitespaceBeforeClippingSplitButtons() {
let measuredWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: 10)
let layout = TabBarLayout(
tabBarHeight: 28,
availableWidth: 930,
tabContentWidthExcludingSplitButtonLane: 300,
splitButtonCount: 10,
splitButtonLaneVisible: true,
reservesSplitButtonLane: true,
measuredSplitButtonLaneWidth: measuredWidth
)
XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 630)
func testTabBarLayoutUsesTrailingWhitespaceBeforeClippingSplitButtons() {
let measuredWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: 10)
let layout = TabBarLayout(
tabBarHeight: 28,
availableWidth: 930,
tabContentWidthExcludingSplitButtonLane: 300,
splitButtonCount: 10,
splitButtonLaneVisible: true,
reservesSplitButtonLane: true,
measuredSplitButtonLaneWidth: measuredWidth
)
// fractionLimit = 930 * 0.25 = 232.5 < trailingWhitespace = 930 - 300 = 630,
// so the whitespace path governs maximumSplitButtonLaneWidth.
let fractionLimit = 930 * TabBarStyling.maximumSplitButtonLaneWidthFraction
XCTAssertLessThan(fractionLimit, 630, "Test precondition: whitespace must exceed fractionLimit")
XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 630)

@austinywang
austinywang merged commit b5dd1f0 into main May 19, 2026
5 checks passed
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