feat(tabs): Tab Layout setting with by case (A) and ledger (B), reversible state order, cleaner tiles (#426)

Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' |
'ledger' | 'classic', default 'state'), the first row of App Settings,
Appearance, Tabs, so the old and the new strip are one choice apart.

- By case (option A): each case's tabs sit in one .tab-cluster box in
  first-appearance order, labelled with the case and its count, coloured
  by a stable hash into the session palette. Membership is
  _mobileOverviewCaseFor(), the home screens' own match. Inside a box with
  company a generated w75-api-gateway reads w75; the -<case> stays in the
  DOM in a .tab-name-case span only .tabs-clusters hides. The incremental
  render path rebuilds only when the cluster structure key changes. The
  rail and the sidebar get a labelled section per case; phones dissolve
  the boxes into the chip row. Drag stays inside one box.
- Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an
  auto-fill column grid of equal cells in mono type with a 3px status
  bar. Its markup is identical to classic's.
- State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the
  by-state groups so needs you can be the bottom row.
- By state: the label column is measured to the widest label on screen
  and the labels are right-aligned in it, instead of a fixed 92px gutter
  that left short labels far from their tabs.
- Tiles: a three-row grid (label, value, bar) with pixel line-heights in
  the bundled JetBrains Mono, 36px like the header. The bar used to lie
  over a fixed 28px tile, and a taller system mono (SF Mono) pushed the
  value into it. The WS tile's grid moved onto an inner .connection-tile
  span because JS writes the indicator's display inline. Compact uses the
  same font and a matched WS size.

Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed
order in test/tab-triage.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 04:08:02 +02:00
parent 9b0d305223
commit e3dfbf6591
15 changed files with 1025 additions and 142 deletions
+36 -14
View File
@@ -501,8 +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('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -2528,7 +2528,8 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
tabGrouping: document.getElementById('appSettingsTabGrouping').value,
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -3453,7 +3454,8 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
tabGrouping: 'state',
tabArrangement: 'state',
tabStateOrder: 'urgent-first',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -3555,6 +3557,22 @@ Object.assign(CodemanApp.prototype, {
return now === before ? undefined : now;
},
/**
* The stored tab layout, or the default. Anything but the four known values
* (an absent key, a value from a newer build) reads as 'state', the default
* (Discussion #426, option C).
*/
resolveTabArrangement(settings) {
const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement;
return value === 'case' || value === 'ledger' || value === 'classic' ? value : 'state';
},
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
resolveTabStateOrder(settings) {
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
return value === 'urgent-last' ? 'urgent-last' : 'urgent-first';
},
/**
* 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',
@@ -3815,14 +3833,18 @@ 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;
// The tab layout rides on a fourth attribute, for the same reason: it is
// applied by the render paths (inline `order` plus headings, or cluster
// boxes), so a flip has to re-render, and the gates in app.js
// (`isTabTriage()`, `isTabClusters()`, `isTabLedger()`) read one attribute
// per pass instead of re-parsing localStorage.
const previousArrangement = root.dataset.tabArrangement || 'state';
const arrangement = this.resolveTabArrangement(settings);
root.dataset.tabArrangement = arrangement;
// Which end the state groups start from; read by _tabTriageLayout().
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
const stateOrder = this.resolveTabStateOrder(settings);
root.dataset.tabStateOrder = stateOrder;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
@@ -3847,7 +3869,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 || previousGrouping !== grouping;
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.syncTerminalGeometry?.();
@@ -4120,7 +4142,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabGrouping', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',