feat(tabs): no label on the idle row (#426)

Idle is the default state of a tab, so naming it only adds noise. The
idle group is now quiet: its heading element stays (it anchors the row's
order band and, as the first row, holds its place beside the brand) but
draws no label or count. Needs you, Waiting and Working are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 06:29:40 +02:00
parent bb5fd5ee97
commit 55cafc282f
6 changed files with 28 additions and 12 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 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)
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, 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 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.** 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). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `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.
+3 -2
View File
@@ -55,8 +55,9 @@ per device, so a sidebar on your desktop does not force one onto your phone.
| **Working** | A turn is running. |
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
In the header each group is a row with its name and count on the left; a group with more
tabs than fit on one line continues under its own tabs. **State Order → Needs you at the
In the header each group is a row with its name and count on the left (Idle, the quiet
default, carries no label); a group with more tabs than fit on one line continues on the
next line. **State Order → Needs you at the
bottom** turns the rows the other way up, so the needs-you row sits right above the
terminal. Empty groups are not shown. These are the same states the phone overview and the
desktop home rail use, and tabs move between groups on their own as their state changes.
+5 -2
View File
@@ -4880,7 +4880,10 @@ class CodemanApp {
};
for (const group of wanted.values()) {
const head = ensure('tab-triage-head', group.key);
if (!head.firstElementChild) {
// A quiet group (idle) keeps its heading as the row's anchor but draws
// no label or count.
head.classList.toggle('tab-triage-head--quiet', !!group.quiet);
if (!group.quiet && !head.firstElementChild) {
const label = document.createElement('span');
label.className = 'tab-triage-label';
label.textContent = group.label;
@@ -4889,7 +4892,7 @@ class CodemanApp {
head.append(label, count);
}
const count = String(group.count);
if (head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
if (!group.quiet && 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]);
+6 -2
View File
@@ -684,11 +684,14 @@ function sortSessionsByActivity(rows) {
// after those and the line break that ends the header row at the very end.
//
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
// `quiet` groups keep their heading element (it anchors the row and holds the
// row's place beside the brand) but draw no text: everything quiet is the
// default state of a tab, so naming it only adds noise.
const TAB_TRIAGE_GROUPS = [
{ key: 'needs', label: 'Needs you' },
{ key: 'waiting', label: 'Waiting' },
{ key: 'working', label: 'Working' },
{ key: 'idle', label: 'Idle' },
{ key: 'idle', label: 'Idle', quiet: true },
];
const TAB_TRIAGE_GROUP_OF_STATE = {
@@ -729,7 +732,7 @@ function tabTriageGroupFor(state, exited) {
* @returns {{
* order: Map<string, number>,
* webOrder: Map<string, number>,
* groups: Array<{key: string, label: string, count: number, headOrder: number, breakOrder: number}>
* groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
* }} `groups` lists only the non-empty groups, in display order (most urgent
* first, or last with `reverse`).
*/
@@ -772,6 +775,7 @@ function computeTabTriageLayout(rows, webviewIds, options) {
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
key: group.key,
label: group.label,
quiet: !!group.quiet,
count: counts[group.key],
headOrder: baseOf[group.key],
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
+12 -4
View File
@@ -55,6 +55,8 @@ describe('CodemanTabTriage (pure)', () => {
it('has four groups, most urgent first', () => {
expect(triage.GROUPS.map((g) => g.key)).toEqual(['needs', 'waiting', 'working', 'idle']);
expect(triage.GROUPS.map((g) => g.label)).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']);
// Only idle is quiet: it keeps a heading element but draws no label.
expect(triage.GROUPS.filter((g) => (g as { quiet?: boolean }).quiet).map((g) => g.key)).toEqual(['idle']);
});
it('folds the six home-screen states into the four groups', () => {
@@ -237,7 +239,11 @@ describe('tab grouping in the render paths (app.js)', () => {
const heads = () =>
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')]
.sort((a, b) => orderOf(a) - orderOf(b))
.map((h) => `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`);
.map((h) =>
h.classList.contains('tab-triage-head--quiet')
? `${h.dataset.triageGroup}:quiet`
: `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`
);
beforeEach(() => {
document.body.innerHTML = '';
@@ -246,7 +252,9 @@ describe('tab grouping in the render paths (app.js)', () => {
it('draws a heading per non-empty state and places every tab in its group', () => {
makeApp()._fullRenderSessionTabs();
expect(container().classList.contains('tabs-triage')).toBe(true);
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'Idle:1']);
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'idle:quiet']);
// The idle row keeps its heading as an anchor, with nothing drawn in it.
expect(container().querySelector('.tab-triage-head[data-triage-group="idle"]')!.textContent).toBe('');
expect(visual()).toEqual([
'head:needs',
's3',
@@ -269,7 +277,7 @@ describe('tab grouping in the render paths (app.js)', () => {
const app = makeApp();
document.documentElement.dataset.tabStateOrder = 'urgent-last';
app._fullRenderSessionTabs();
expect(heads()).toEqual(['Idle:1', 'Working:2', 'Waiting:1', 'Needs you:1']);
expect(heads()).toEqual(['idle:quiet', 'Working:2', 'Waiting:1', 'Needs you:1']);
expect(visual()).toEqual([
'head:idle',
'web:w1',
@@ -312,7 +320,7 @@ describe('tab grouping in the render paths (app.js)', () => {
app._renderSessionTabsImmediate();
expect(fullRender).not.toHaveBeenCalled();
expect(tab('s2')).toBe(s2);
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'Idle:3']);
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'idle:quiet']);
expect(container().querySelector('.tab-triage-head[data-triage-group="needs"]')).toBeNull();
expect(container().querySelector('.tab-triage-break[data-triage-group="needs"]')).toBeNull();
expect(visual()).toEqual(['head:waiting', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);