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;