fix: keep the L-shaped tab strip whichever tab is selected

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>
This commit is contained in:
timkjrandClaude Opus 5.5 committed 2026-10-31 16:41:26 -05:00
1 parent f1f7eec4d6
commit 6a23fecb1c
5 files changed
+151 -8

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 also falls back (the whole first line would drop below the toolbar). 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 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).
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`.
+29 -3
View File
@@ -7035,6 +7035,8 @@ class CodemanApp {
container.classList.remove('tabs-corner');
container.style.removeProperty('--tab-corner-width');
container.style.removeProperty('--tab-corner-height');
container.style.removeProperty('--tab-corner-first-max');
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit');
};
// A strip past its max-height scrolls, and the float would scroll its tabs
// under the toolbar, which stays put: keep the plain wrap. The plain strip
@@ -7073,12 +7075,36 @@ class CodemanApp {
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.
const firstTab = container.querySelector(':scope > .session-tab');
if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) {
// 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
// follow the selection; the first tab's name is trimmed to the room
// instead. Only a toolbar that leaves no usable room keeps the plain strip.
// The room is what the float (rounded up below) leaves, less the tab's 2px
// right margin and a pixel for subpixel layout; a tab at the exact width
// still wrapped under the float.
const floatWidth = Math.max(0, Math.ceil(contentRight - box.left + 8));
const room = Math.floor(contentRight - floatWidth - contentLeft) - 3;
if (room < TAB_CORNER_MIN_FIRST_TAB) {
off();
return;
}
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`;
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();
}
if (firstTab && firstTab.offsetWidth > room) 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`);
}
const width = `${floatWidth}px`;
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
container.style.setProperty('--tab-corner-width', width);
+5
View File
@@ -281,6 +281,11 @@ function resolveTabRailKeyboardWidth(input = {}) {
// the gap is what tells the user there is more strip to swipe to.
const TAB_SCROLL_REVEAL_PX = 16;
// Narrowest room beside the toolbar the L-shaped header strip trims its first
// tab into (`_syncTabStripCorner()`, app.js). Less than this keeps the plain
// wrap: a tab squeezed further shows its number and buttons but no name.
const TAB_CORNER_MIN_FIRST_TAB = 120;
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
// the strip horizontally (desktop wraps to a second row instead and never
// scrolls), so the active tab can sit entirely outside the visible slice with
+7
View File
@@ -651,6 +651,13 @@ 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. */
.session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit {
max-width: var(--tab-corner-first-max, none);
}
.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}
+109 -4
View File
@@ -133,13 +133,118 @@ describe('L-shaped header tab strip (real Chromium)', () => {
await p.close();
});
it('keeps the plain wrap when the first tab is too wide for the room beside the toolbar', async () => {
/** Give tab `n` (1-based) a name long enough to be wider than the room beside the toolbar. */
const longName = (n: number) =>
p.evaluate((n) => {
const tab = document.querySelectorAll<HTMLElement>('#sessionTabs > .session-tab')[n - 1];
const name = tab.querySelector<HTMLElement>('.tab-name')!;
name.textContent = 'a-very-long-session-name '.repeat(12);
}, n);
/** Is the first tab beside the toolbar (not dropped below it), and does it carry the trim? */
const firstTab = () =>
p.evaluate(() => {
const tab = document.querySelector('#sessionTabs > .session-tab')!;
const r = tab.getBoundingClientRect();
const b = document.getElementById('headerRight')!.getBoundingClientRect();
return {
besideToolbar: r.top < b.bottom && r.right <= b.left,
trimmed: tab.classList.contains('tab-corner-fit'),
};
});
/** Each visible tab's top, in strip order: equal tops share a row. */
const rowTops = async () => (await visibleTabs()).map((r) => Math.round(r.top));
it('trims a first tab too wide for the room beside the toolbar instead of dropping the corner', async () => {
await open({ tabs: 40 });
await longName(1);
await relayout();
expect(await cornered()).toBe(true);
expect(await overlapping()).toEqual([]);
// Beside the toolbar, not dropped a row below it.
const [first] = await visibleTabs();
const b = await toolbar();
expect(first.right).toBeLessThanOrEqual(b.left);
expect(first.top).toBeLessThan(b.bottom);
// The rows below still run the full width, several tabs to a row.
const tops = await rowTops();
expect(tops[1]).toBe(tops[2]);
await p.close();
});
it('keeps the corner whichever tab is selected, though selecting widens the first tab past the room', async () => {
await open({ tabs: 40 });
// Real selection: the gear and close show only on the active tab. The first
// tab's name is grown until it fits the room unselected but not selected.
await p.evaluate(() => {
for (const tab of document.querySelectorAll('#sessionTabs > .session-tab')) {
tab.insertAdjacentHTML('beforeend', '<span class="tab-gear">⚙</span><span class="tab-close">×</span>');
}
});
await relayout();
await p.evaluate(() => {
const strip = document.getElementById('sessionTabs')!;
const room = parseFloat(strip.style.getPropertyValue('--tab-corner-first-max'));
const tab = strip.querySelector<HTMLElement>(':scope > .session-tab')!;
const name = tab.querySelector('.tab-name')!;
while (tab.offsetWidth < room - 10) name.textContent += 'x';
});
const select = async (n: number) => {
await p.evaluate((n) => {
document
.querySelectorAll('#sessionTabs > .session-tab')
.forEach((tab, i) => tab.classList.toggle('active', i === n - 1));
}, n);
await relayout();
return { cornered: await cornered(), overlapping: await overlapping(), first: await firstTab() };
};
expect(await select(3)).toEqual({
cornered: true,
overlapping: [],
first: { besideToolbar: true, trimmed: false },
});
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 },
});
await p.close();
});
it('re-trims the first tab when the room grows but is still too narrow for it', async () => {
await open({ tabs: 40 });
await longName(1);
// One pass must get it right: the toolbar's ResizeObserver would run a
// second one and hide a first pass that read the last trim as the width.
await p.evaluate(() => (window as any).__app._tabCornerObserver?.disconnect());
const toolbarWidth = async (px: string | null) => {
await p.evaluate((px) => {
const root = document.documentElement;
if (px === null) {
root.removeAttribute('data-header-right-width');
root.style.removeProperty('--header-right-width');
} else {
root.setAttribute('data-header-right-width', 'set');
root.style.setProperty('--header-right-width', px);
}
}, px);
await relayout();
};
await toolbarWidth('500px');
expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true });
await toolbarWidth('300px');
expect(await cornered()).toBe(true);
expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true });
expect(await overlapping()).toEqual([]);
await p.close();
});
it('keeps the plain wrap when the toolbar leaves no usable room beside it', async () => {
await open({ tabs: 40 });
await p.evaluate(() => {
const head = document.querySelector('.header')!.getBoundingClientRect();
const right = document.getElementById('headerRight')!.getBoundingClientRect();
const first = document.querySelector<HTMLElement>('#sessionTabs > .session-tab')!;
first.style.minWidth = first.style.width = `${Math.round(head.width - right.width / 2)}px`;
const right = document.getElementById('headerRight')!;
right.style.minWidth = `${Math.round(head.width - 150)}px`;
});
await relayout();
expect(await cornered()).toBe(false);