feat(tabs,header): group tabs by state and add header stats styles (#426)

Two directions from Discussion #426, each a per-device setting and the
new default.

Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.

Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.

Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 04:08:01 +02:00
parent ac94f339ac
commit 9b0d305223
16 changed files with 1543 additions and 26 deletions
+81 -3
View File
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
@@ -500,6 +501,8 @@ Object.assign(CodemanApp.prototype, {
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsTabRailSort').value =
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
document.getElementById('appSettingsTabGrouping').value =
(settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -2475,6 +2478,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
@@ -2524,6 +2528,7 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
tabGrouping: document.getElementById('appSettingsTabGrouping').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -3448,6 +3453,7 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
tabGrouping: 'state',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -3549,6 +3555,63 @@ Object.assign(CodemanApp.prototype, {
return now === before ? undefined : now;
},
/**
* The stored header-stats style, or the default. Anything but the three
* known values (an absent key, a value from a newer build) reads as 'tiles',
* the default (the tile variant of Discussion #426's option G).
*/
resolveHeaderStatsStyle(settings) {
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
return value === 'classic' || value === 'compact' ? value : 'tiles';
},
/**
* Apply a header-stats style: the `data-header-stats` attribute every rule in
* the "Header stats styles" block of styles.css keys on, plus the two DOM
* moves the clustered styles need.
*
* The template keeps the classic order, where the connection indicator sits
* before the font controls and the plan-usage chip near the end of the header.
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
* windows), so the indicator moves into #headerSystemStats as its first child
* and the chip moves right after it. Comment anchors left at the template
* positions are what 'classic' moves them back to, so switching back restores
* the header exactly.
*
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
* is hidden with `display: none`, and the WS readout must not disappear with
* it. Both elements keep their ids, so every writer (setConnectionStatus,
* updatePlanUsageChip) finds them wherever they sit.
*
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
*/
applyHeaderStatsStyle({ style, showSystemStats }) {
document.documentElement.dataset.headerStats = style;
const stats = document.getElementById('headerSystemStats');
const conn = document.getElementById('connectionIndicator');
const plan = document.getElementById('planUsageChip');
if (!stats || !conn || !plan) return;
if (!this._headerStatsAnchors) {
const connAnchor = document.createComment(' connection indicator (classic position) ');
const planAnchor = document.createComment(' plan usage chip (classic position) ');
conn.before(connAnchor);
plan.before(planAnchor);
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
}
const anchors = this._headerStatsAnchors;
const clustered = style !== 'classic';
if (clustered && showSystemStats) {
if (stats.firstElementChild !== conn) stats.prepend(conn);
} else if (anchors.conn.nextSibling !== conn) {
anchors.conn.after(conn);
}
if (clustered) {
if (stats.nextElementSibling !== plan) stats.after(plan);
} else if (anchors.plan.nextSibling !== plan) {
anchors.plan.after(plan);
}
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -3579,6 +3642,12 @@ Object.assign(CodemanApp.prototype, {
if (tokenCountEl) {
tokenCountEl.style.display = showTokenCount ? '' : 'none';
}
// After the System Stats visibility above: whether WS joins the stats pill
// depends on the pill being shown.
this.applyHeaderStatsStyle({
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
showSystemStats,
});
// Hide lifecycle log button when setting is disabled
// Default OFF: the lifecycle-log document icon is opt-in; the default header
@@ -3746,6 +3815,15 @@ Object.assign(CodemanApp.prototype, {
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
root.dataset.tabRailSort = sort;
// Grouping rides on a fourth attribute, for the same reason: it is applied
// as inline `order` plus heading elements the render paths emit, so a flip
// has to re-render, and `isTabTriage()` (app.js) reads one attribute per
// pass instead of re-parsing localStorage. Anything but an explicit 'none'
// is 'state', the default (Discussion #426, option C).
const previousGrouping = root.dataset.tabGrouping || 'state';
const grouping = (settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
root.dataset.tabGrouping = grouping;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
@@ -3769,7 +3847,7 @@ Object.assign(CodemanApp.prototype, {
// the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect.
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.syncTerminalGeometry?.();
@@ -4039,10 +4117,10 @@ Object.assign(CodemanApp.prototype, {
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabGrouping', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',