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
+244 -34
View File
@@ -19790,7 +19790,7 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
}
/* ============================================================
Tab grouping by state (tabGrouping: 'state', the default;
Tab grouping by state (tabArrangement: 'state', the default;
Discussion #426 option C)
============================================================
app.js writes a flex `order` per tab plus one heading and one row break per
@@ -19848,14 +19848,19 @@ 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. */
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
--tab-triage-gutter: 92px;
padding-left: calc(0.25rem + var(--tab-triage-gutter));
padding-left: calc(0.25rem + var(--tab-triage-gutter, 92px));
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
width: var(--tab-triage-gutter);
margin-left: calc(-1 * var(--tab-triage-gutter));
justify-content: flex-end;
width: var(--tab-triage-gutter, 92px);
margin-left: calc(-1 * var(--tab-triage-gutter, 92px));
padding: 0 8px 0 0;
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
@@ -19877,6 +19882,183 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta
display: none;
}
/* ============================================================
Tab clusters by case (tabArrangement: 'case'; Discussion #426 option A)
============================================================
app.js wraps each case's tabs in a .tab-cluster box (_renderTabClusters):
a labelled box per case with two or more tabs, an unlabelled box with a
colour swatch per case with one, and a box per web tab. --cluster-color is
the case's colour from the session palette. Tabs inside keep their own
markup; only the `-<case>` part of a generated name (.tab-name-case) is
hidden, and only here. */
.session-tabs.tabs-clusters .tab-name-case {
display: none;
}
.tab-cluster-label {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 0 6px 0 2px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
white-space: nowrap;
color: var(--text-dim);
user-select: none;
pointer-events: none;
}
.tab-cluster-count {
font-weight: 600;
letter-spacing: 0;
color: var(--text-muted);
}
.tab-cluster-swatch {
flex: none;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--cluster-color, var(--text-muted));
}
/* Header strip: each cluster is an inline box that wraps inside itself when a
case has more tabs than fit on a line. */
.session-tabs-host > .session-tabs.tabs-clusters {
gap: 6px;
}
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 1px 3px 1px 8px;
border: 1px solid var(--control-border);
border-left: 3px solid var(--cluster-color, var(--control-border));
border-radius: 8px;
background: var(--control-bg);
}
/* A case with one tab and a web tab are boxed but not labelled: the single
case keeps its swatch, a web tab has no case at all. */
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single {
border-left-width: 1px;
padding-left: 6px;
}
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-name,
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-count {
display: none;
}
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-label {
padding: 0 2px;
}
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--web {
padding-left: 3px;
}
/* Side rail and sidebar: every case is a section with its label on top
(vertical space is cheap there, so a one-tab case is labelled too). The
label goes first by `order`, because a sorted rail orders the rows inside
each cluster. */
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters > .tab-cluster,
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters > .tab-cluster {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: stretch;
gap: 2px;
min-width: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters > .tab-cluster + .tab-cluster,
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters > .tab-cluster + .tab-cluster {
margin-top: 6px;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters .tab-cluster-label,
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters .tab-cluster-label {
order: -1;
padding: 6px 6px 3px;
}
/* The collapsed sidebar is a 44px icon rail with no room for a label. */
html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .tab-cluster-label {
display: none;
}
/* ============================================================
Ledger (tabArrangement: 'ledger'; Discussion #426 option B)
============================================================
The flat list, in tab order, on an aligned column grid: equal cells, mono
type, a 3px status bar on the left instead of the dot. Desktop header strip
only (updateTabOverflowMode() keeps it wrapping); narrower strips, the rail
and the sidebar keep the plain list. Nothing here touches the markup. */
@media (min-width: 768px) {
.session-tabs-host > .session-tabs.tabs-ledger {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
align-content: start;
gap: 4px 6px;
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab {
--ledger-bar: var(--green);
min-width: 0;
gap: 7px;
padding: 4px 8px 4px 11px;
border: 1px solid var(--control-border);
border-radius: 4px;
background: var(--control-bg);
box-shadow: inset 3px 0 0 var(--ledger-bar);
font-family: 'JetBrains Mono', var(--font-mono, monospace);
font-size: 12px;
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-info {
flex: 1 1 auto;
min-width: 0;
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-number {
width: auto;
min-width: 1ch;
height: auto;
border: 0;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 11px;
font-weight: 500;
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-status {
display: none;
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab.tab-alert-idle {
--ledger-bar: var(--yellow);
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab:is(.tab-alert-action, :has(> .tab-status.error)) {
--ledger-bar: var(--red);
}
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab:is(.tab-agent-exited, .session-tab--web, :has(> .tab-status.stopped)) {
--ledger-bar: var(--text-muted);
}
}
/* ============================================================
Header stats styles (headerStatsStyle; Discussion #426 option G)
============================================================
@@ -19890,8 +20072,7 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta
/* Parts only the two new styles draw. */
.stat-spark,
.connection-tile-label,
.connection-tile-value,
.connection-tile,
.header-plan-usage .pu-ring,
.header-plan-usage .pu-meter {
display: none;
@@ -19912,9 +20093,21 @@ html[data-header-stats='compact'] .header-system-stats {
}
html[data-header-stats='compact'] .header-system-stats > .connection-indicator {
font-size: inherit;
color: var(--text);
}
html[data-header-stats='compact'] .header-system-stats > .connection-indicator > .connection-dot {
width: 7px;
height: 7px;
}
/* Same bundled face as Tiles, so the pills read the same on every machine. */
html[data-header-stats='compact'] .header-system-stats,
html[data-header-stats='compact'] .header-plan-usage {
font-family: 'JetBrains Mono', var(--font-mono, monospace);
}
html[data-header-stats='compact'] .header-system-stats > .connection-indicator::after,
html[data-header-stats='compact'] .header-system-stats > .stat-item + .stat-item::before {
content: '·';
@@ -20009,31 +20202,37 @@ html[data-header-stats='compact'] .header-plan-usage .pu-ring {
}
/* --- Tiles: label over value, a bar underneath, no icons ----------------- */
/* Each tile is a three-row grid (label, value, bar) with pixel line-heights,
so its height comes from the layout, not from whichever monospace face the
machine falls back to. The bar is a real row, never laid over the bottom
edge: an absolutely placed bar under a fixed-height tile is what put the
value on top of it with a taller system font (SF Mono). The bundled
JetBrains Mono comes first so every machine draws the same tile. */
html[data-header-stats='tiles'] .header-system-stats {
align-items: center;
gap: 6px;
padding: 0;
}
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator,
html[data-header-stats='tiles'] .header-system-stats > .stat-item,
html[data-header-stats='tiles'] .header-plan-usage .pu-win {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1px;
min-width: 44px;
height: 28px;
padding: 0 8px 3px;
html[data-header-stats='tiles'] .header-plan-usage .pu-win,
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-tile {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 9px 14px 2px;
row-gap: 2px;
align-content: center;
justify-items: start;
min-width: 54px;
/* 2 + 9 + 2 + 14 + 2 + 2 + 3 plus the 1px borders: 36px, the header's own
height, so the tiles never make the header grow. */
padding: 2px 10px 3px;
box-sizing: border-box;
border-radius: 6px;
border-radius: 7px;
background: var(--control-bg);
border: 1px solid var(--control-border);
overflow: hidden;
font-family: 'SF Mono', Monaco, monospace;
line-height: 1.05;
font-family: 'JetBrains Mono', var(--font-mono, monospace);
white-space: nowrap;
}
/* The tile label and value: the stat items already carry a label and a value,
@@ -20043,10 +20242,12 @@ html[data-header-stats='tiles'] .header-system-stats .stat-label,
html[data-header-stats='tiles'] .connection-tile-label,
html[data-header-stats='tiles'] .header-plan-usage .pu-label {
display: block;
grid-row: 1;
min-width: 0;
font-size: 0.55rem;
font-size: 9px;
line-height: 9px;
font-weight: 600;
letter-spacing: 0.06em;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
opacity: 1;
@@ -20056,10 +20257,13 @@ html[data-header-stats='tiles'] .header-system-stats .stat-value,
html[data-header-stats='tiles'] .connection-tile-value,
html[data-header-stats='tiles'] .header-plan-usage .pu-val {
display: block;
grid-row: 2;
min-width: 0;
text-align: left;
font-size: 0.72rem;
font-size: 12px;
line-height: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Plan values keep their green/yellow/red; the stat values read as plain text
@@ -20069,6 +20273,13 @@ html[data-header-stats='tiles'] .connection-tile-value {
color: var(--text);
}
/* The WS tile: JS sets the indicator's `display` inline (flex, or none when
there is nothing to report), so the grid lives on the .connection-tile span
inside it and the indicator itself only keeps the flex box it already has. */
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator {
padding: 0;
}
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-dot,
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-text {
display: none;
@@ -20088,14 +20299,13 @@ html[data-header-stats='tiles'] .connection-tile-value.offline {
color: var(--red);
}
/* The bar runs along the bottom edge of the tile. */
/* The bar is the third row, full tile width. The WS tile has no bar, and its
empty third row keeps it the same height as its neighbours. */
html[data-header-stats='tiles'] .header-system-stats .stat-bar,
html[data-header-stats='tiles'] .header-plan-usage .pu-meter {
display: block;
position: absolute;
left: 6px;
right: 6px;
bottom: 3px;
grid-row: 3;
justify-self: stretch;
width: auto;
height: 2px;
border-radius: 1px;
@@ -20149,11 +20359,11 @@ html[data-header-stats='tiles'] .header-plan-usage .pu-win-idle {
of its own so it does not read as a stray mark between the tiles. */
html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
justify-content: center;
min-width: 44px;
height: 28px;
padding: 0 8px;
min-width: 54px;
height: 36px;
padding: 0 10px;
box-sizing: border-box;
border-radius: 6px;
border-radius: 7px;
background: var(--control-bg);
border: 1px solid var(--control-border);
color: var(--text-muted);