From 6a23fecb1c3c08cd625c1979a1b8c6e6ab660efc Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 31 Oct 2026 16:41:26 -0500 Subject: [PATCH] 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 --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 32 +++++++- src/web/public/constants.js | 5 ++ src/web/public/styles.css | 7 ++ test/tab-strip-corner.browser.test.ts | 113 +++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 8 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ebba7973..d5abda8b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 255132a6..3f588494 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 742ac363..428d8925 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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 diff --git a/src/web/public/styles.css b/src/web/public/styles.css index cd9039c0..c3a19c5d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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)); } diff --git a/test/tab-strip-corner.browser.test.ts b/test/tab-strip-corner.browser.test.ts index ba3065aa..ffb3f9aa 100644 --- a/test/tab-strip-corner.browser.test.ts +++ b/test/tab-strip-corner.browser.test.ts @@ -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('#sessionTabs > .session-tab')[n - 1]; + const name = tab.querySelector('.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', '⚙×'); + } + }); + await relayout(); + await p.evaluate(() => { + const strip = document.getElementById('sessionTabs')!; + const room = parseFloat(strip.style.getPropertyValue('--tab-corner-first-max')); + const tab = strip.querySelector(':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('#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);