From 3dafc89095cc631e2ae9700a3fa66713a4fb65f0 Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 26 Oct 2026 19:19:06 -0500 Subject: [PATCH 1/2] feat(tabs): L-shaped header strip, wrapped rows run under the toolbar A wrapped classic tab strip stopped at the toolbar's left edge on every row, so the space under the header chips stayed empty and long tabs took a row each. Once the strip wraps, the toolbar now sits over the strip's top-right corner and the strip lays its tabs out inline around a float sized to the toolbar's box (_syncTabStripCorner), so only the rows beside the toolbar stop short of it and every later row runs the header's full width. - Wrap is still measured with the toolbar in flow (the corner is dropped before the measure), so the single-row strip is unchanged. - A ResizeObserver on the toolbar re-decides wrap + corner and redraws the lineage routes when a reading or the divider changes its size. - Falls back to the plain strip when the first tab cannot fit beside the toolbar (it would otherwise leave an empty band). - Classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the tab rail keep their own layouts. --- src/web/public/app.js | 81 ++++++++++++++++++++++++++++++++++++++- src/web/public/styles.css | 38 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index db6551312..15f4ab3ea 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5910,7 +5910,7 @@ class CodemanApp { // tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode() // drops it in sidebar mode, but drop it here too so nothing paints wrapped // for a frame before the next measure. - if (sidebar) tabsEl.classList.remove('tabs-auto-wrap'); + if (sidebar) tabsEl.classList.remove('tabs-auto-wrap', 'tabs-corner'); // Collapse/expand changes whether the filter is reachable, so re-evaluate it // here too — not only at the render tails. this.applySidebarFilter(this._sidebarFilter); @@ -6901,7 +6901,14 @@ class CodemanApp { updateTabOverflowMode() { const container = this.$('sessionTabs'); if (!container) return; + // The corner takes the toolbar out of the header's flow, which widens the + // strip, so it goes before the wrap is measured and comes back once decided. + container.classList.remove('tabs-corner'); + this._decideTabOverflow(container); + this._syncTabStripCorner(container); + } + _decideTabOverflow(container) { // Lineage routing room (session-lineage.js) changes the strip's padding and // row gap, so it is decided before the wrap is measured below. this._syncLineageGutter?.(); @@ -6963,6 +6970,78 @@ class CodemanApp { container.classList.toggle('tabs-auto-wrap', shouldWrap); } + /** + * L-shaped header strip: once the classic strip wraps, the toolbar + * (#headerRight) sits over the strip's top-right corner (styles.css, + * `.tabs-corner`) and the rows under the first one run the header's full + * width beneath it. The strip keeps the toolbar's corner clear with a float + * sized here, from the toolbar's own box, so a toolbar that wraps onto more + * lines (header-divider.js) or gains a reading keeps every tab out from + * under it. The grouped arrangements (state, case, ledger) lay their rows + * out themselves and keep the plain strip. + */ + _syncTabStripCorner(container) { + const right = this.$('headerRight'); + const on = + !!right && + container.offsetParent !== null && + !!container.parentElement?.classList.contains('session-tabs-host') && + (container.classList.contains('tabs-auto-wrap') || container.classList.contains('tabs-two-rows')) && + !container.classList.contains('tabs-triage') && + !container.classList.contains('tabs-clusters') && + !container.classList.contains('tabs-ledger') && + MobileDetection.getDeviceType() === 'desktop'; + const off = () => { + container.classList.remove('tabs-corner'); + container.style.removeProperty('--tab-corner-width'); + container.style.removeProperty('--tab-corner-height'); + }; + if (!on) { + off(); + return; + } + container.classList.add('tabs-corner'); + if (!this._tabCornerObserver && typeof ResizeObserver !== 'undefined') { + let frame = 0; + // A toolbar that changes size (a reading arrives, the divider settles) + // moves the tabs without a render: re-decide the wrap and the corner, and + // redraw the lineage routes that follow the tabs. + this._tabCornerObserver = new ResizeObserver(() => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + const strip = this.$('sessionTabs'); + if (!strip || !/\btabs-(corner|auto-wrap|two-rows)\b/.test(strip.className)) return; + this.updateTabOverflowMode(); + this.updateConnectionLines?.(); + }); + }); + this._tabCornerObserver.observe(right); + } + const style = getComputedStyle(container); + const strip = container.getBoundingClientRect(); + const box = right.getBoundingClientRect(); + // The float sits in the strip's scrolled content, at its padding edge. + const contentTop = strip.top + parseFloat(style.paddingTop) - container.scrollTop; + 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. + const firstTab = container.querySelector(':scope > .session-tab'); + if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) { + off(); + return; + } + const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}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); + } + if (container.style.getPropertyValue('--tab-corner-height') !== height) { + container.style.setProperty('--tab-corner-height', height); + } + } + /** * True when a case cluster in the header strip wraps inside its own box: a case * wider than the whole strip (styles.css caps a box at the strip's width). The diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49af0d3e8..a72465a2a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -616,6 +616,44 @@ body { padding-left: var(--lineage-spine-channel); } +/* L-shaped strip (`_syncTabStripCorner()`, app.js): a wrapped classic strip + runs the header's full width, the toolbar sits over its top-right corner, + and only the rows beside the toolbar stop short of it. A flex line cannot + be shortened on its own, so the strip lays its tabs out inline, around a + float the size of the toolbar's box (measured into --tab-corner-width and + --tab-corner-height). The strip scrolls, so it contains the float and is + never shorter than the toolbar it now holds up. Inline tabs space their + rows with a bottom margin (the old row-gap, lineage's included); the strip + carries no font size of its own, so the whitespace between tabs is 0 wide. */ +.header:has(> .session-tabs-host > .session-tabs.tabs-corner) > .header-right { + position: absolute; + top: 0.35rem; + right: 0.75rem; +} + +.session-tabs-host > .session-tabs.tabs-corner { + display: block; + font-size: 0; + padding-bottom: 0; +} + +.session-tabs-host > .session-tabs.tabs-corner::before { + content: ''; + float: right; + width: var(--tab-corner-width, 0px); + height: var(--tab-corner-height, 0px); +} + +.session-tabs-host > .session-tabs.tabs-corner > .session-tab { + display: inline-flex; + vertical-align: top; + margin: 0 2px var(--lineage-row-gap, 4px) 0; +} + +.session-tabs-host > .session-tabs.lineage-tree.tabs-corner { + padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap)); +} + .session-tabs::-webkit-scrollbar { width: 4px; height: 0; From 285aa0b2b2da949524a910e5fdfcbe816ca23deb Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 26 Oct 2026 21:35:04 -0500 Subject: [PATCH 2/2] fix(tabs): keep the plain wrap when the strip scrolls, add a browser test Review follow-up for the L-shaped header strip: - _syncTabStripCorner() keeps the plain wrap when the plain strip is already past --tab-strip-max-height, checked before .tabs-corner goes on: the float scrolled with the tabs while the toolbar stayed put, so a scrolled strip put tabs under #headerRight, and a re-render of a scrolled two-rows strip sized the float from the scrolled frame. The "- scrollTop" in contentTop is gone (always 0 now). - A strip just under its max-height that passes it once the float is in falls back to the plain wrap as well. - test/tab-strip-corner.browser.test.ts (real Chromium, real header and styles.css), listed in BROWSER_TEST_GLOBS. - src/web/CLAUDE.md Tab layouts entry describes the L-shaped classic strip. --- config/test-suites.ts | 1 + src/web/CLAUDE.md | 2 +- src/web/public/app.js | 15 +- src/web/public/styles.css | 5 +- test/tab-strip-corner.browser.test.ts | 227 ++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 test/tab-strip-corner.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index d66abd586..407450321 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -27,6 +27,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-focus.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', 'test/header-divider.browser.test.ts', + 'test/tab-strip-corner.browser.test.ts', 'test/header-host-sensors.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 1c884dfce..1d02a3635 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -38,7 +38,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `