fix: cap the first tab on every pass, before a selection widens it (#748)

selectSession() toggles .active at once while _syncTabStripCorner() only runs in
the debounced render, so a cap set after measuring left a newly selected first
tab wider than the room and dropped the first row below the toolbar until then.
max-width changes nothing on a narrower tab, so the cap is simply always on, and
the clear-before-measure and finish-the-animation loops go. The no-usable-room
test uses 8 tabs: at 40 the scroll fallback fires first and it passed at any
threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-04 16:48:57 +01:00
1 parent 823013226d
commit d9d6fad698
4 files changed
+24 -19

No files matched your search

+1 -1
View File
@@ -1047,7 +1047,7 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The first tab is measured with `tab-corner-fit` cleared, or a room that grew would read the last trim as its width. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
+7 -12
View File
@@ -7073,8 +7073,6 @@ class CodemanApp {
const contentTop = strip.top + parseFloat(style.paddingTop);
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
// A first tab that cannot fit beside the toolbar would drop the whole first
// line below it and leave an empty band: keep the plain strip instead.
// The first tab must fit beside the toolbar, or the whole first line drops
// below it and leaves an empty band. A selected tab is wider (gear and
// close show), so falling back to the plain strip here made the layout
@@ -7090,17 +7088,14 @@ class CodemanApp {
return;
}
const firstTab = container.querySelector(':scope > .session-tab');
// Cleared first, the first tab's included: the last pass's trim would
// otherwise be measured as its width, and a room that grew would untrim a
// tab still too wide for it.
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit');
// A selection change slides the gear and close open or shut (a width
// transition), and this runs in the same render: finish the first tab's
// slide, or it is judged by the width it is leaving.
for (const anim of firstTab?.getAnimations?.({ subtree: true }) ?? []) {
if (/^(width|padding)/.test(anim.transitionProperty ?? '')) anim.finish();
// Capped every time, not only once it measures too wide: selectSession()
// widens a newly selected tab at once and this pass is debounced, so a cap
// set only here afterwards would leave the first row below the toolbar until
// it runs. The cap changes nothing on a tab narrower than the room.
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) {
if (tab !== firstTab) tab.classList.remove('tab-corner-fit');
}
if (firstTab && firstTab.offsetWidth > room) firstTab.classList.add('tab-corner-fit');
firstTab?.classList.add('tab-corner-fit');
if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) {
container.style.setProperty('--tab-corner-first-max', `${room}px`);
}
+3 -3
View File
@@ -651,9 +651,9 @@ body {
margin: 0 2px var(--lineage-row-gap, 4px) 0;
}
/* A first tab wider than the room beside the toolbar (a selected one shows its
gear and close) has its name trimmed to fit, so the layout never depends on
which tab is selected. */
/* The first tab is capped to the room beside the toolbar: one wider than that (a
selected one shows its gear and close) has its name trimmed to fit, so the
layout never depends on which tab is selected. */
.session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit {
max-width: var(--tab-corner-first-max, none);
}
+13 -3
View File
@@ -200,14 +200,22 @@ describe('L-shaped header tab strip (real Chromium)', () => {
expect(await select(3)).toEqual({
cornered: true,
overlapping: [],
first: { besideToolbar: true, trimmed: false },
first: { besideToolbar: true, trimmed: true },
});
expect(await select(1)).toEqual({ cornered: true, overlapping: [], first: { besideToolbar: true, trimmed: true } });
expect(await select(3)).toEqual({
cornered: true,
overlapping: [],
first: { besideToolbar: true, trimmed: false },
first: { besideToolbar: true, trimmed: true },
});
// selectSession() widens the tab at once and the corner sync is debounced: the
// cap has to be on already, or the first row drops below the toolbar until then.
await p.evaluate(() => {
document
.querySelectorAll('#sessionTabs > .session-tab')
.forEach((tab, i) => tab.classList.toggle('active', i === 0));
});
expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true });
await p.close();
});
@@ -240,7 +248,9 @@ describe('L-shaped header tab strip (real Chromium)', () => {
});
it('keeps the plain wrap when the toolbar leaves no usable room beside it', async () => {
await open({ tabs: 40 });
// Few enough that the strip does not scroll: at 40 the scroll fallback turns the
// corner off before the room check is reached, and this would pass at any threshold.
await open({ tabs: 8 });
await p.evaluate(() => {
const head = document.querySelector('.header')!.getBoundingClientRect();
const right = document.getElementById('headerRight')!;