Skip to content

fix(tabs): keep the L-shaped strip whichever tab is selected - #748

Open
timkjr wants to merge 1 commit into
Ark0N:masterfrom
timkjr:fix/tab-corner-first-tab
Open

timkjr wants to merge 1 commit into
Ark0N:masterfrom
timkjr:fix/tab-corner-first-tab

Conversation

@timkjr

@timkjr timkjr commented Oct 31, 2026

Copy link
Copy Markdown

What

Follow-up to #721. With the L-shaped header strip on, selecting the first tab could switch the whole strip back to the plain wrapped layout: the toolbar took its own column again, and with long tab names every other tab dropped onto a row of its own. Selecting any other tab brought the full-width rows back, so the layout depended on which tab was selected.

The cause is #721's first-tab fallback. A selected tab is wider than an unselected one (its gear and close buttons appear), so a first tab that fits beside the toolbar unselected can stop fitting once selected, and the strip then dropped .tabs-corner.

With this change the first tab is trimmed to the room beside the toolbar instead (its name ellipsized), and the strip keeps the L-shape whichever tab is selected.

How

  • _syncTabStripCorner() no longer falls back when the first tab is too wide. It adds tab-corner-fit to the first tab, and styles.css caps that tab at max-width: var(--tab-corner-first-max), so .tab-info ellipsizes the name.
  • The plain wrap remains only when the toolbar leaves less than TAB_CORNER_MIN_FIRST_TAB (120px, constants.js) beside it. That depends on the toolbar, not on the selection.
  • Three measuring details, each pinned by a test that fails without it:
    • The room is taken from the rounded-up float width, less the tab's 2px margin and 1px for subpixel layout. A tab at the exact width still wrapped under the float.
    • The first tab is measured with its trim cleared. Otherwise a room that grew (a wider window, a narrower toolbar) read the last trim as the tab's width and untrimmed a tab still too wide for it.
    • The first tab's gear/close width/padding transitions are finished before it is measured. A selection change starts them in the same render, so otherwise the tab was judged by the width it was leaving.
  • docs/architecture-invariants.md: the L-shaped strip section's first-tab sentence is updated to match.

Tests

test/tab-strip-corner.browser.test.ts (browser suite) has three new cases, replacing the old first-tab fallback case:

  • a too-wide first tab is trimmed beside the toolbar, and the rows below stay full width;
  • a first tab that fits unselected but not selected stays beside the toolbar with tab 3 selected, then tab 1, then tab 3 again (real .active with gear and close);
  • the first tab is re-trimmed correctly when the room grows but is still too narrow (the toolbar's ResizeObserver is disconnected so a single pass must get it right).

A case for a toolbar that leaves no usable room checks the remaining fallback. All 14 cases pass.

Also checked by hand in headless Chromium with four long-named tabs at 900 / 1000 / 1100 / 1400 px, selecting each tab in turn: the strip stays L-shaped and the first tab stays beside the toolbar every time.

npm run typecheck, lint, format:check, check:frontend-syntax, check:browser-excludes pass. Full npm test passes except two environment-only failures in this checkout: frontend-public-tooling (spreadsheet assets not prepared) and xterm-private-api (a different xterm in the local node_modules).

🤖 Generated with Claude Code

The wrapped header strip fell back to the plain wrap whenever its first
tab was wider than the room beside the toolbar. A selected tab is wider
(its gear and close show), so selecting the first tab flipped the whole
strip back: the toolbar took its own column again and, with long names,
every tab took a line of its own. Selecting any other tab brought the
full-width rows back.

The first tab is now trimmed to the room instead (tab-corner-fit, a
max-width from --tab-corner-first-max, its name ellipsized). Only a
toolbar that leaves less than TAB_CORNER_MIN_FIRST_TAB (120px) beside
it keeps the plain wrap, which does not depend on the selection.

Two measuring details make the trim hold:
- the room comes from the rounded-up float width, less the tab's 2px
  margin and a pixel for subpixel layout (a tab at the exact width still
  wrapped under the float);
- the first tab is measured with its trim cleared and its gear/close
  width transitions finished, or a grown room reads the last trim as the
  width and a selection change is judged by the width the tab is leaving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thank you @timkjr for this follow-up to #721, and welcome to Codeman. The L-shaped header strip now trims a too-wide first tab to the room beside the toolbar instead of falling back to the plain wrap, so the layout no longer changes depending on which tab is selected. The three new browser cases fail on master and pass here, and the full test suite is green.

Three small things I will apply on top when merging:

  1. src/web/public/app.js:7103: the first tab gets tab-corner-fit every time instead of only when it measures too wide. max-width changes nothing on a tab narrower than the room, and it closes a window your fix leaves open: selectSession() toggles .active at once through _updateActiveTabImmediate(), but _syncTabStripCorner() only runs in the debounced renderSessionTabs() pass (100 ms at least, longer while sessions are busy). Until then a newly selected first tab is wider than the room and the whole first row drops below the toolbar (measured in Chromium: tab top 33px against a toolbar bottom of 32px). With the cap always on, the clear-before-measure loop and the getAnimations().finish() loop are no longer needed. The selection test's trimmed: false expectations and the architecture-invariants sentence about measuring with tab-corner-fit cleared change to match. The comment at line 7098 also goes, since the toggle and the corner sync do not run in the same render.
  2. test/tab-strip-corner.browser.test.ts:243: the "no usable room" case changes to open({ tabs: 8 }). With 40 tabs the plain strip already scrolls past its max-height, so the older scroll fallback turns the corner off before the new TAB_CORNER_MIN_FIRST_TAB check runs, and the test passes even with the threshold set to 0. With 8 tabs it reaches the room check, passes at 120 and fails at 0.
  3. src/web/public/app.js:7076-7077: the old comment ending "keep the plain strip instead" goes, since it contradicts the new comment below it.

You do not need to do anything here. If you would rather push these yourself, go ahead before the merge. Thanks again for the careful write-up and the hand-checked widths.

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.

2 participants