fix(tabs): state rows start under the brand, labels left-aligned (#426)

In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 06:25:26 +02:00
parent bebf0db792
commit bb5fd5ee97
5 changed files with 95 additions and 14 deletions
+1 -1
View File
@@ -333,7 +333,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 `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (`tabStateOrder: 'urgent-last'` flips it, needs you at the bottom): rows in the desktop header strip with right-aligned labels in a measured gutter, sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (`tabStateOrder: 'urgent-last'` flips it, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): `tiles` / `compact` (pill + sparklines + plan rings) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (sparklines, tile words, rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
+1 -1
View File
@@ -871,7 +871,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label right-aligned in a left gutter that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
⚠️ **By state is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass and can still empty a group or fill a new one. Inside a header row tabs keep TAB order; a sorted rail ranks each section the way it ranks the flat rail. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap`; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width.
+47 -6
View File
@@ -4890,6 +4890,9 @@ class CodemanApp {
}
const count = String(group.count);
if (head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
// The first row's heading is the one that starts beside the brand in the
// header strip (styles.css); every later row starts under it.
head.classList.toggle('tab-triage-head--lead', group === triage.groups[0]);
const headOrder = String(group.headOrder);
if (head.style.order !== headOrder) head.style.order = headOrder;
const brk = ensure('tab-triage-break', group.key);
@@ -4905,20 +4908,34 @@ class CodemanApp {
}
/**
* Size the header strip's label column to the widest label on screen, so no
* row carries a fixed gutter's worth of empty space before its tabs (labels
* are right-aligned in it, styles.css). Measured only when the label text
* changes (a group appears, goes, or its count gains a digit), and once more
* when the web fonts finish loading, since that changes every width. The
* vertical lists do not use the gutter, so they are never measured.
* Size the header strip's two measured lengths (styles.css, "Header strip,
* wrapping"): `--tab-triage-gutter`, the label column, as wide as the widest
* label on screen so a row never carries a fixed gutter's worth of empty
* space; and `--tab-triage-brand`, the brand's width, because the brand sits
* over the strip's top-left corner and only the FIRST row starts beside it,
* every later row starting under it.
*
* The labels are measured only when their text changes (a group appears,
* goes, or its count gains a digit) and once more when the web fonts finish
* loading. The brand is watched by a ResizeObserver (a display-name change,
* the sidebar toggle appearing), so a render pass never forces a layout read
* for it. The vertical lists use neither length and are never measured.
*/
_sizeTabTriageGutter(container, triage) {
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
if (!triage || !inHeader) {
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
if (container.style.getPropertyValue('--tab-triage-brand')) container.style.removeProperty('--tab-triage-brand');
this._tabTriageGutterKey = null;
return;
}
this._watchTabTriageBrand(container);
if (Number.isFinite(this._tabTriageBrandWidth)) {
const brand = `${this._tabTriageBrandWidth}px`;
if (container.style.getPropertyValue('--tab-triage-brand') !== brand) {
container.style.setProperty('--tab-triage-brand', brand);
}
}
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
if (key === this._tabTriageGutterKey) return;
let widest = 0;
@@ -4940,6 +4957,30 @@ class CodemanApp {
}
}
/**
* Keep `_tabTriageBrandWidth` (the header brand plus the gap after it) in
* step with the brand, once per page. The first observation arrives right
* after `observe()`, so the width is known from the first frame on.
*/
_watchTabTriageBrand(container) {
if (this._tabTriageBrandObserver !== undefined) return;
const brand = container.closest('.header')?.querySelector(':scope > .header-brand');
if (!brand || typeof ResizeObserver !== 'function') {
this._tabTriageBrandObserver = null;
return;
}
const gap = 8;
this._tabTriageBrandWidth = Math.ceil(brand.getBoundingClientRect().width + gap);
this._tabTriageBrandObserver = new ResizeObserver((entries) => {
const box = entries[0]?.borderBoxSize?.[0];
const width = Math.ceil((box ? box.inlineSize : brand.getBoundingClientRect().width) + gap);
if (width === this._tabTriageBrandWidth) return;
this._tabTriageBrandWidth = width;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
this._tabTriageBrandObserver.observe(brand);
}
/**
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
* group. Inside a group the rows sit in tab order, so a drop there moves the
+25 -6
View File
@@ -19848,21 +19848,40 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
/* Header strip, wrapping (desktop): one row per state. Each heading sits in a
left gutter, so a group too long for one line continues under its own tabs
rather than under its heading, and each row ends with a full-width break. */
/* The gutter is as wide as the widest label on screen (app.js measures it into
--tab-triage-gutter whenever the labels change; 92px is only the first-paint
fallback), and every label is right-aligned in it, so each one sits right
next to its own tabs while the rows still start on one line. */
/* The label column is as wide as the widest label on screen (app.js measures
it into --tab-triage-gutter whenever the labels change; 92px is only the
first-paint fallback) and the labels are left-aligned in it.
The brand leaves the flow and sits over the strip's top-left corner, so the
strip starts at the header's left edge: the first row's heading (the lead)
is pushed past the brand by --tab-triage-brand, and every later row starts
at the edge, under "Codeman", instead of leaving that corner empty. The
rows under the first one line up with each other in the label column. */
.header:has(> .session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)) > .header-brand {
position: absolute;
top: 0.35rem;
left: 0.75rem;
z-index: 1;
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
padding-left: calc(0.25rem + var(--tab-triage-gutter, 92px));
padding-left: var(--tab-triage-gutter, 92px);
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
justify-content: flex-end;
justify-content: flex-start;
width: var(--tab-triage-gutter, 92px);
margin-left: calc(-1 * var(--tab-triage-gutter, 92px));
padding: 0 8px 0 0;
}
/* The first row starts beside the brand and lines up with nothing else, so its
label takes its own width and its tabs follow it directly. */
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head--lead {
width: auto;
margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px));
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
display: block;
flex: 0 0 100%;
+21
View File
@@ -283,6 +283,17 @@ describe('tab grouping in the render paths (app.js)', () => {
]);
});
it('marks the first row heading as the lead, in either state order', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const lead = () =>
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head--lead')].map((h) => h.dataset.triageGroup);
expect(lead()).toEqual(['needs']);
document.documentElement.dataset.tabStateOrder = 'urgent-last';
app._renderSessionTabsImmediate();
expect(lead()).toEqual(['idle']);
});
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
makeApp()._fullRenderSessionTabs();
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
@@ -431,6 +442,16 @@ describe('tab grouping wiring (static)', () => {
);
});
it('lets the rows after the first start under the brand, labels left-aligned', () => {
expect(css).toMatch(
/\.header:has\(> \.session-tabs-host > \.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\)\) > \.header-brand \{\s*position: absolute;/
);
expect(css).toMatch(/> \.tab-triage-head \{\s*justify-content: flex-start;/);
expect(css).toMatch(
/> \.tab-triage-head--lead \{\s*width: auto;\s*margin-left: calc\(var\(--tab-triage-brand, 100px\) - var\(--tab-triage-gutter, 92px\)\);/
);
});
it('hides the headings on phones, whose strip stays one scrolling row', () => {
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
});