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
+210 -23
View File
@@ -4797,7 +4797,7 @@ class CodemanApp {
}
/**
* True when the tab list groups by state (`tabGrouping: 'state'`, the default;
* True when the tab list groups by state (`tabArrangement: 'state'`, the default;
* Discussion #426 option C): a row per state in the header strip, a section
* per state in the flat side rail and the sidebar, most urgent on top.
*
@@ -4807,7 +4807,7 @@ class CodemanApp {
* grouped projection is on, and the grouped tree renders as it always did.
*/
isTabTriage() {
return document.documentElement.dataset.tabGrouping === 'state';
return document.documentElement.dataset.tabArrangement === 'state';
}
/**
@@ -4840,7 +4840,8 @@ class CodemanApp {
});
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
return window.CodemanTabTriage.layout(rows, webviewIds);
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
}
/**
@@ -4860,6 +4861,7 @@ class CodemanApp {
*/
_syncTabTriageChrome(container, triage) {
if (!container) return;
this._lastTabTriage = triage;
container.classList.toggle('tabs-triage', !!triage);
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
@@ -4899,25 +4901,184 @@ class CodemanApp {
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
if (web.style.order !== value) web.style.order = value;
}
this._sizeTabTriageGutter(container, triage);
}
/**
* A drag in a state-grouped strip may only reorder WITHIN a group. Inside a
* group the rows sit in tab order, so a drop there moves the tab exactly where
* it was dropped; across groups the dragged tab would stay in its own group
* (its state did not change) and land somewhere the user did not put it.
* Groups are bands of `order` values, so comparing bands is enough.
* 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.
*/
_isTabDropAcrossTriageGroups(targetTab) {
_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');
this._tabTriageGutterKey = null;
return;
}
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
if (key === this._tabTriageGutterKey) return;
let widest = 0;
for (const head of container.querySelectorAll(':scope > .tab-triage-head')) {
let width = 0;
for (const part of head.children) width += part.getBoundingClientRect().width;
widest = Math.max(widest, width + 5 * Math.max(0, head.children.length - 1));
}
// Hidden (display: none on phones, or a detached strip): nothing to size.
if (!widest) return;
this._tabTriageGutterKey = key;
container.style.setProperty('--tab-triage-gutter', `${Math.ceil(widest + 10)}px`);
if (!this._tabTriageFontsHooked && document.fonts?.ready) {
this._tabTriageFontsHooked = true;
document.fonts.ready.then(() => {
this._tabTriageGutterKey = null;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
}
}
/**
* 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
* tab exactly where it was dropped; across groups the dragged tab would stay
* in its own group (its state or case did not change) and land somewhere the
* user did not put it. State groups are bands of `order` values, so comparing
* bands is enough; case clusters are boxes, so the box decides.
*/
_isTabDropAcrossGroups(targetTab) {
const container = this.$('sessionTabs');
if (!container?.classList.contains('tabs-triage') || !this.draggedTabId || !targetTab) return false;
if (!container || !this.draggedTabId || !targetTab) return false;
const triage = container.classList.contains('tabs-triage');
const clusters = container.classList.contains('tabs-clusters');
if (!triage && !clusters) return false;
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
if (!dragged) return false;
// Clusters are real boxes: a drop belongs to the box it lands in.
if (clusters) return dragged.closest('.tab-cluster') !== targetTab.closest('.tab-cluster');
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
return band(dragged) !== band(targetTab);
}
/** True when the tab list is clustered by case (`tabArrangement: 'case'`, Discussion #426 option A). */
isTabClusters() {
return document.documentElement.dataset.tabArrangement === 'case';
}
/**
* True when the header strip is drawn as a ledger (`tabArrangement: 'ledger'`,
* Discussion #426 option B): the flat list on an aligned column grid with a
* status bar per cell. Pure CSS on `.tabs-ledger`, scoped to the desktop
* header strip; the rail and the sidebar keep their flat list.
*/
isTabLedger() {
return document.documentElement.dataset.tabArrangement === 'ledger';
}
/**
* Which case a session belongs to, for clustering: the case whose path is the
* longest prefix of its working directory (`_mobileOverviewCaseFor()`, the
* home screens' own match), else the directory itself, else the session alone.
*/
_tabClusterIdentity(session, id) {
const dir = (session.workingDir || '').replace(/\/+$/, '');
const match =
dir && typeof this._mobileOverviewCaseFor === 'function' ? this._mobileOverviewCaseFor(dir, this.cases) : null;
if (match) return { key: match.path, label: match.name || '' };
if (dir) return { key: dir, label: dir.split('/').pop() || dir };
return { key: `session:${id}`, label: '' };
}
/**
* The cluster layout for one render pass, or null when the list is not
* clustered. Named groups in the vertical rail win, exactly as for the state
* grouping. `key` is the whole structure as a string: the incremental render
* path compares it with the last full render's and rebuilds when it differs,
* because a cluster is a real box and a patch in place cannot move a tab into
* another one. Membership only changes when sessions come and go (already a
* full rebuild) or when the case list arrives, so this rarely fires.
*
* @param {Array<string>} ids live session ids, in tab order
* @param {object|null} groupProjection the grouped rail's projection, if any
*/
_tabClusterLayout(ids, groupProjection) {
if (groupProjection || !this.isTabClusters() || !window.CodemanTabClusters) return null;
const rows = [];
for (const id of ids) {
const session = this.sessions.get(id);
if (session) rows.push({ id, ...this._tabClusterIdentity(session, id) });
}
const clusters = window.CodemanTabClusters.compute(rows);
// Only a cluster with company drops the case from its tab names.
const labelFor = new Map();
for (const cluster of clusters) {
if (cluster.ids.length > 1) for (const id of cluster.ids) labelFor.set(id, cluster.label);
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
const key = JSON.stringify([clusters.map((c) => [c.key, c.label, c.ids]), webviewIds]);
return { clusters, labelFor, webviewIds, key };
}
/**
* The cluster boxes for the full render: one box per case, labelled with its
* colour swatch, name and count, and a box per open web tab, which has no
* case. The header strip shows only the swatch for a case with one tab
* (styles.css); the rail and the sidebar label every case. Rows are the
* caller's own markup, so a tab is byte-identical to the flat strip's apart
* from its name split.
*/
_renderTabClusters(layout, rowHtml, webviewSlotStart) {
const parts = [];
for (const cluster of layout.clusters) {
const rows = cluster.ids.map((id) => rowHtml.get(id) || '').join('');
const single = cluster.ids.length < 2;
const head =
'<span class="tab-cluster-label" aria-hidden="true"><span class="tab-cluster-swatch"></span>' +
`<span class="tab-cluster-name" data-i18n-skip>${escapeHtml(cluster.label)}</span>` +
`<span class="tab-cluster-count">${cluster.ids.length}</span></span>`;
parts.push(
`<div class="tab-cluster${single ? ' tab-cluster--single' : ''}" role="presentation" data-cluster-key="${escapeHtml(cluster.key)}" style="--cluster-color: var(--session-${cluster.color})">${head}${rows}</div>`
);
}
layout.webviewIds.forEach((wid, i) => {
const tab = this.renderWebviewTab?.(wid, webviewSlotStart + i) || '';
if (tab) parts.push(`<div class="tab-cluster tab-cluster--single tab-cluster--web" role="presentation">${tab}</div>`);
});
return parts.join('');
}
/**
* The tab label, as markup. #232: a described name (`w3-x: fix login`) shows
* just the description, the generated id kept in a hidden prefix span. Inside
* a case cluster a generated `w75-api-gateway` shows `w75`, the `-api-gateway`
* kept in a `.tab-name-case` span that only `.tabs-clusters` hides, so the full
* name stays in the DOM (copy, find-in-page, the rename editor).
*/
_tabNameHtml(name, clusterLabel) {
const parsed = parseSessionPrefix(name);
if (parsed && parsed.suffix) {
return `<span class="tab-name-prefix">${escapeHtml(parsed.prefix)}: </span>${escapeHtml(parsed.suffix)}`;
}
const split = clusterLabel ? window.CodemanTabClusters?.nameSplit(name, clusterLabel) : null;
if (split) return `${escapeHtml(split.shown)}<span class="tab-name-case">${escapeHtml(split.hidden)}</span>`;
return escapeHtml(name);
}
/**
* The arrangement classes on #sessionTabs that are not owned by a sync of
* their own (`tabs-triage` is `_syncTabTriageChrome()`'s): `tabs-clusters`
* while case clusters are drawn, `tabs-ledger` while the ledger is on. The
* ledger never applies inside the grouped rail.
*/
_syncTabArrangementClasses(container, { clusters, groupProjection }) {
if (!container) return;
container.classList.toggle('tabs-clusters', !!clusters);
container.classList.toggle('tabs-ledger', this.isTabLedger() && !groupProjection);
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -5581,10 +5742,16 @@ class CodemanApp {
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
// Case clusters are boxes too: the same rule, keyed on their structure.
const clusterLayout = this._tabClusterLayout(
this.sessionOrder.filter((sid) => this.sessions.has(sid)),
groupProjection
);
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection);
!this._isTabGroupStructureStale(groupProjection) &&
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
@@ -5707,11 +5874,17 @@ class CodemanApp {
const _p = parseSessionPrefix(name);
if (nameEl.dataset.fullName !== name) {
nameEl.replaceChildren();
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
if (_p && _p.suffix) {
const prefix = document.createElement('span');
prefix.className = 'tab-name-prefix';
prefix.textContent = `${_p.prefix}: `;
nameEl.append(prefix, document.createTextNode(_p.suffix));
} else if (_split) {
const caseSpan = document.createElement('span');
caseSpan.className = 'tab-name-case';
caseSpan.textContent = _split.hidden;
nameEl.append(document.createTextNode(_split.shown), caseSpan);
} else {
nameEl.textContent = name;
}
@@ -5787,6 +5960,7 @@ class CodemanApp {
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
@@ -5856,10 +6030,11 @@ class CodemanApp {
}
// Grouped by state, the header strip IS rows (one per state), so it always
// wraps: the row breaks only take effect in a wrapping flex line. Narrower
// screens keep the single scrolling row above, where the headings read as
// inline dividers instead.
if (container.classList.contains('tabs-triage')) {
// wraps: the row breaks only take effect in a wrapping flex line. The ledger
// is a grid of rows, so the same holds. Narrower screens keep the single
// scrolling row above, where state headings read as inline dividers and the
// ledger stays the plain strip.
if (container.classList.contains('tabs-triage') || container.classList.contains('tabs-ledger')) {
container.classList.add('tabs-auto-wrap');
return;
}
@@ -5958,12 +6133,15 @@ class CodemanApp {
// strip's markup is byte-identical to before.
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state (tabGrouping, the default): the same `order` mechanism,
// Grouped by state (tabArrangement 'state', the default): the same `order` mechanism,
// one band of values per state. Null in the grouped rail and with grouping
// off, and the rows then carry exactly the inline order they did before.
const groupProjection = this._projectTabGroups();
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// Clustered by case: rows are wrapped in one box per case below, and a tab
// in a cluster with company drops the `-<case>` from its name.
const clusterLayout = this._tabClusterLayout(liveIds, groupProjection);
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
@@ -6005,9 +6183,7 @@ class CodemanApp {
// JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name);
const tabLabel = parsedName && parsedName.suffix
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
: escapeHtml(name);
const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
@@ -6054,7 +6230,12 @@ class CodemanApp {
_tabIdx++;
}
if (groupProjection) {
if (clusterLayout) {
// Clustered by case. Web tabs keep their flat-strip Alt+N slot (after
// every session), each in a box of its own.
parts.push(this._renderTabClusters(clusterLayout, rowHtml, _tabIdx));
this._hiddenTabGroupByRef = new Map();
} else if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
@@ -6080,6 +6261,7 @@ class CodemanApp {
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
@@ -6089,6 +6271,7 @@ class CodemanApp {
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
@@ -6165,7 +6348,11 @@ class CodemanApp {
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
tabs.sort((a, b) => orderOf(a) - orderOf(b));
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
const currentIndex = tabs.indexOf(document.activeElement);
@@ -7366,7 +7553,7 @@ class CodemanApp {
tab.addEventListener('dragover', (e) => {
// Grouped by state: a tab in another group is not a drop target, and
// leaving the event alone (no preventDefault) is what shows "no drop".
if (this._isTabDropAcrossTriageGroups(tab)) return;
if (this._isTabDropAcrossGroups(tab)) return;
e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
@@ -7396,7 +7583,7 @@ class CodemanApp {
tab.classList.remove('drag-over-left', 'drag-over-right');
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
if (this._isTabDropAcrossTriageGroups(tab)) return;
if (this._isTabDropAcrossGroups(tab)) return;
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
+78 -5
View File
@@ -658,7 +658,7 @@ function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
}
// Tab grouping by state (`tabGrouping: 'state'`, Discussion #426 option C).
// Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
//
// The tab list answers "who wants me?" the way the home screens do: a row (the
// header strip) or a section (the flat side rail, the sidebar) per state, most
@@ -723,18 +723,23 @@ function tabTriageGroupFor(state, exited) {
* strip, the activity sort's position on a sorted rail). Rows without one keep
* the order they were passed in.
* @param {Array<string>} webviewIds open web tabs, in their own tab order
* @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
* way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
* strip read from the bottom. Rows inside a group keep their order.
* @returns {{
* order: Map<string, number>,
* webOrder: Map<string, number>,
* groups: Array<{key: string, label: string, count: number, headOrder: number, breakOrder: number}>
* }} `groups` lists only the non-empty groups, most urgent first.
* }} `groups` lists only the non-empty groups, in display order (most urgent
* first, or last with `reverse`).
*/
function computeTabTriageLayout(rows, webviewIds) {
function computeTabTriageLayout(rows, webviewIds, options) {
const list = Array.isArray(rows) ? rows : [];
const webs = Array.isArray(webviewIds) ? webviewIds : [];
const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
const baseOf = {};
const counts = {};
TAB_TRIAGE_GROUPS.forEach((group, i) => {
sequence.forEach((group, i) => {
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
counts[group.key] = 0;
});
@@ -764,7 +769,7 @@ function computeTabTriageLayout(rows, webviewIds) {
});
counts.idle += webOrder.size;
const groups = TAB_TRIAGE_GROUPS.filter((group) => counts[group.key] > 0).map((group) => ({
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
key: group.key,
label: group.label,
count: counts[group.key],
@@ -775,6 +780,68 @@ function computeTabTriageLayout(rows, webviewIds) {
return { order, webOrder, groups };
}
// Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
//
// One cluster per case, in the order the case first appears in the tab order,
// so the strip keeps the user's arrangement at the case level. Inside a cluster
// with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
// redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
// read as 7 things. A case with one tab is still its own (unlabelled) box.
//
// Cluster colours come from the session palette (`--session-<colour>`) by a
// stable hash of the case key, so a case keeps its colour across reloads and
// devices without anything being stored.
//
// Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
/** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
function tabClusterColorFor(key) {
const text = String(key || '');
let hash = 5381;
for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
}
/**
* Group tabs by case.
* @param {Array<{id: string, key: string, label: string}>} rows live sessions in
* tab order; `key` identifies the case (its path), `label` names it
* @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
* clusters in first-appearance order, members in tab order
*/
function computeTabClusters(rows) {
const clusters = [];
const byKey = new Map();
for (const row of Array.isArray(rows) ? rows : []) {
if (!row || typeof row.id !== 'string') continue;
const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
let cluster = byKey.get(key);
if (!cluster) {
cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
byKey.set(key, cluster);
clusters.push(cluster);
}
cluster.ids.push(row.id);
}
return clusters;
}
/**
* Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
* shows and the case suffix it hides, or null when the name is anything else
* (a custom name, a described `w3-x: fix login`, another case's name). Case is
* compared case-insensitively; the hidden part keeps its original spelling so
* the full name is still in the DOM.
* @returns {{shown: string, hidden: string}|null}
*/
function tabClusterNameSplit(name, label) {
if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
const match = name.match(/^([ws]\d+)(-.+)$/);
if (!match) return null;
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1100,6 +1167,12 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanTabClusters = {
COLORS: TAB_CLUSTER_COLORS,
colorFor: tabClusterColorFor,
compute: computeTabClusters,
nameSplit: tabClusterNameSplit,
};
window.CodemanTabTriage = {
GROUPS: TAB_TRIAGE_GROUPS,
STRIDE: TAB_TRIAGE_STRIDE,
+25 -14
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';document.documentElement.dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabGrouping='state';document.documentElement.dataset.headerStats='classic';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -154,8 +154,7 @@
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
<span class="connection-dot" id="connectionDot"></span>
<span class="connection-text" id="connectionText"></span>
<span class="connection-tile-label" id="connectionTileLabel"></span>
<span class="connection-tile-value" id="connectionTileValue"></span>
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
</div>
<div class="header-font-controls">
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
@@ -2085,6 +2084,28 @@
<div class="set-group">
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="tab layout grouping group by state triage needs you waiting working idle case clusters ledger columns classic old new">
<div class="set-row-text">
<span class="set-row-label">Tab Layout</span>
<span class="set-row-desc">By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.</span>
</div>
<select id="appSettingsTabArrangement" class="set-select">
<option value="state">By state (default)</option>
<option value="case">By case (clusters)</option>
<option value="ledger">Ledger (aligned columns)</option>
<option value="classic">Classic (as before)</option>
</select>
</div>
<div class="set-row has-field" data-search="state order reverse needs you bottom top rows sections">
<div class="set-row-text">
<span class="set-row-label">State Order</span>
<span class="set-row-desc">For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.</span>
</div>
<select id="appSettingsTabStateOrder" class="set-select">
<option value="urgent-first">Needs you on top (default)</option>
<option value="urgent-last">Needs you at the bottom</option>
</select>
</div>
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
<div class="set-row-text">
<span class="set-row-label">Tab Orientation</span>
@@ -2095,16 +2116,6 @@
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab grouping group by state triage needs you waiting working idle rows sections">
<div class="set-row-text">
<span class="set-row-label">Tab Grouping</span>
<span class="set-row-desc">By state puts what needs you on top: a row each for needs you (red), waiting (yellow), working and idle in the header, and the same sections in the side rail and sidebar. Tabs move between groups as their state changes; Alt+1..9 keeps the tab order. Named tab groups in the side rail take precedence.</span>
</div>
<select id="appSettingsTabGrouping" class="set-select">
<option value="state">By state (default)</option>
<option value="none">None (tab order)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Rows</span>
@@ -2118,7 +2129,7 @@
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Order</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Grouping by state it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Layout by state or by case it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
</div>
<select id="appSettingsTabRailSort" class="set-select">
<option value="activity">By activity (home screen order)</option>
+12 -1
View File
@@ -755,7 +755,7 @@ html.mobile-init .file-browser-panel {
font-weight: 500;
}
/* Grouped by state (tabGrouping), the phone strip keeps its one scrolling
/* Grouped by state (tabArrangement 'state'), the phone strip keeps its one scrolling
row: the tabs still come in state order, most urgent first, but the
headings would cost chips and the dots already say which state is which.
The phone overview is where the labelled sections live. */
@@ -763,6 +763,17 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* Clustered by case, the same holds: the boxes dissolve into the one chip
row (in cluster order) and the labels go, since every chip still names
its session. */
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
display: contents;
}
:where(.header) .tab-cluster-label {
display: none;
}
/* Only the active tab shows its action icons on a phone (see below), so on
every other tab the container is empty but still a flex item, and its gap
made the chip visibly wider on the right than on the left. */
+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',
+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);
+17 -6
View File
@@ -1404,13 +1404,24 @@ export const SettingsUpdateSchema = z
*/
tabRailSort: z.enum(['activity', 'manual']).optional(),
/**
* Tab grouping. Display key (per-device).
* 'state' = the strip is split into state rows (needs you, waiting,
* working, idle; Discussion #426 option C), and the flat side
* rail and the sidebar get the same sections. The default.
* 'none' = one flat list in tab order, as before.
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
* (per-device).
* 'state' = a row per state in the header strip (needs you, waiting,
* working, idle; option C), sections in the flat side rail and
* the sidebar. The default.
* 'case' = one cluster per case (option A): a labelled box in the strip,
* a section in the side rail and the sidebar.
* 'ledger' = the flat list on an aligned column grid with a status bar
* per cell (option B). Header strip on desktop only.
* 'classic' = one flat list in tab order, as before.
*/
tabGrouping: z.enum(['state', 'none']).optional(),
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
/**
* Which end the state groups start from when `tabArrangement` is 'state'.
* Display key (per-device). 'urgent-first' = needs you on top (the
* default); 'urgent-last' = the other way up, needs you in the bottom row.
*/
tabStateOrder: z.enum(['urgent-first', 'urgent-last']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip