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.
This commit is contained in:
timkjr committed 2026-10-26 19:19:06 -05:00
1 parent 3e06f3c141
commit 3dafc89095
2 files changed
+118 -1

No files matched your search

+80 -1
View File
@@ -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
+38
View File
@@ -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;