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
+1 -1
View File
@@ -201,7 +201,7 @@ describe('header stats wiring (static)', () => {
it('hides the new-style parts by default, so classic looks exactly as before', () => {
expect(css).toMatch(
/\.stat-spark,\s*\.connection-tile-label,\s*\.connection-tile-value,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
/\.stat-spark,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
);
});
+296
View File
@@ -0,0 +1,296 @@
/**
* @fileoverview Tab layouts by case and ledger (`tabArrangement: 'case'` and
* `'ledger'`, Discussion #426 options A and B).
*
* What is pinned, and why it matters:
* - By case, each case's tabs sit in ONE box, in the order the case first
* appears in the tab order, with a colour that is a pure function of the
* case (no storage, the same on every device).
* - Inside a box with company, a generated `w75-api-gateway` reads `w75`, but
* the full name stays in the DOM and in the accessible name; a custom or
* described name is never touched.
* - The Alt+N badges keep counting the tab order, and an incremental pass
* patches tabs in place unless the cluster structure changed.
* - Drag only reorders inside a box. Named groups in the vertical rail win.
* - The ledger changes no markup at all: it is a class on #sessionTabs and
* CSS scoped to the desktop header strip.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
type Cluster = { key: string; label: string; color: string; ids: string[] };
type Clusters = {
COLORS: string[];
colorFor: (key: string) => string;
compute: (rows: Array<{ id: string; key: string; label: string }>) => Cluster[];
nameSplit: (name: string, label: string) => { shown: string; hidden: string } | null;
};
function loadClusters(): Clusters {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanTabClusters: Clusters }).CodemanTabClusters;
}
describe('CodemanTabClusters (pure)', () => {
const C = loadClusters();
it('groups by case in first-appearance order, members in tab order', () => {
const out = C.compute([
{ id: 'a', key: '/c/web', label: 'web' },
{ id: 'b', key: '/c/api', label: 'api' },
{ id: 'c', key: '/c/web', label: 'web' },
]);
expect(out.map((c) => [c.key, c.ids])).toEqual([
['/c/web', ['a', 'c']],
['/c/api', ['b']],
]);
});
it('gives a session with no case a cluster of its own', () => {
const out = C.compute([
{ id: 'a', key: '', label: '' },
{ id: 'b', key: '', label: '' },
]);
expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]);
});
it('colours a case from the session palette, the same way every time', () => {
for (const key of ['/c/web', '/c/api', '/home/x/codeman-cases/long-name', '']) {
expect(C.COLORS).toContain(C.colorFor(key));
expect(C.colorFor(key)).toBe(C.colorFor(key));
}
});
it('only shortens a generated name that carries this case', () => {
expect(C.nameSplit('w75-api-gateway', 'api-gateway')).toEqual({ shown: 'w75', hidden: '-api-gateway' });
expect(C.nameSplit('s2-API-Gateway', 'api-gateway')).toEqual({ shown: 's2', hidden: '-API-Gateway' });
expect(C.nameSplit('w75-api-gateway', 'webshop')).toBeNull();
expect(C.nameSplit('w3-x: fix login', 'x')).toBeNull();
expect(C.nameSplit('DocsBot', 'docsbot')).toBeNull();
expect(C.nameSplit('w1-webshop', '')).toBeNull();
});
});
describe('tab layouts by case and ledger (app.js)', () => {
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.CSS = { escape: (value: string) => value };
window.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\n' +
read('mobile-overview.js') +
'\n' +
read('webview-tabs.js') +
'\n;window.__ClusterCodemanApp = CodemanApp;'
);
CodemanApp = window.__ClusterCodemanApp;
});
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'case') {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
const root = document.documentElement;
root.setAttribute('data-tab-orientation', 'horizontal');
root.dataset.tabRailSort = 'activity';
root.dataset.tabArrangement = arrangement;
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
app.$ = (id: string) => document.getElementById(id);
app.cases = [
{ name: 'webshop', path: '/c/webshop' },
{ name: 'api-gateway', path: '/c/api-gateway' },
];
app.sessions = new Map([
['s1', { id: 's1', name: 'w1-webshop', status: 'idle', workingDir: '/c/webshop' }],
['s2', { id: 's2', name: 'w75-api-gateway', status: 'busy', workingDir: '/c/api-gateway' }],
['s3', { id: 's3', name: 'w3-webshop', status: 'idle', workingDir: '/c/webshop/sub' }],
['s4', { id: 's4', name: 'landing', status: 'idle', workingDir: '/srv/landing/' }],
['s5', { id: 's5', name: 'w4-webshop: fix login', status: 'busy', workingDir: '/c/webshop' }],
]);
app.sessionOrder = ['s1', 's2', 's3', 's4', 's5'];
app.pendingHooks = new Map();
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
app.webviewOrder = ['w1'];
app.activeSessionId = 's2';
app.activeWebviewId = null;
app.tabLayout = null;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => {};
app.updateTabOverflowMode = () => {};
app.updateConnectionLines = vi.fn();
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app._refreshMobileOverviewIfVisible = () => {};
app._refreshHomeSessionsIfVisible = () => {};
app.applySidebarFilter = () => {};
return app;
}
const container = () => document.getElementById('sessionTabs')!;
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
const boxes = () =>
[...container().querySelectorAll<HTMLElement>(':scope > .tab-cluster')].map((box) => ({
name: box.querySelector('.tab-cluster-name')?.textContent ?? null,
count: box.querySelector('.tab-cluster-count')?.textContent ?? null,
single: box.classList.contains('tab-cluster--single'),
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map((t) => t.dataset.id || `web:${t.dataset.webviewId}`),
}));
beforeEach(() => {
document.body.innerHTML = '';
});
it('puts each case in one box, in first-appearance order, a web tab in its own', () => {
makeApp()._fullRenderSessionTabs();
expect(container().classList.contains('tabs-clusters')).toBe(true);
expect(boxes()).toEqual([
// s3 sits in a subdirectory of the case and still joins it.
{ name: 'webshop', count: '3', single: false, rows: ['s1', 's3', 's5'] },
{ name: 'api-gateway', count: '1', single: true, rows: ['s2'] },
// No case matches: the directory names the cluster, trailing slash and all.
{ name: 'landing', count: '1', single: true, rows: ['s4'] },
{ name: null, count: null, single: true, rows: ['web:w1'] },
]);
for (const box of container().querySelectorAll<HTMLElement>(':scope > .tab-cluster:not(.tab-cluster--web)')) {
expect(box.getAttribute('style')).toMatch(/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/);
expect(box.getAttribute('role')).toBe('presentation');
}
});
it('drops the case from a generated name inside a box with company, and only there', () => {
makeApp()._fullRenderSessionTabs();
const nameOf = (id: string) => tab(id).querySelector('.tab-name')!;
expect(nameOf('s1').innerHTML).toBe('w1<span class="tab-name-case">-webshop</span>');
expect(nameOf('s1').textContent).toBe('w1-webshop');
expect(tab('s1').getAttribute('aria-label')).toContain('w1-webshop');
// A described name keeps its #232 treatment.
expect(nameOf('s5').querySelector('.tab-name-prefix')?.textContent).toBe('w4-webshop: ');
// Alone in its box, nothing is redundant, so nothing is hidden.
expect(nameOf('s2').innerHTML).toBe('w75-api-gateway');
});
it('keeps the Alt+N badges on the tab order', () => {
makeApp()._fullRenderSessionTabs();
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
expect(tab('s2').querySelector('.tab-number')?.textContent).toBe('2');
expect(container().querySelector('.session-tab[data-webview-id="w1"] .tab-number')?.textContent).toBe('6');
});
it('patches in place while the clusters stay the same, and rebuilds when they change', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const s1 = tab('s1');
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.sessions.get('s1').status = 'busy';
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(tab('s1')).toBe(s1);
// s4 moves into the webshop case: a different box, so a full rebuild.
app.sessions.get('s4').workingDir = '/c/webshop';
app._renderSessionTabsImmediate();
expect(full).toHaveBeenCalledTimes(1);
expect(boxes()[0].rows).toEqual(['s1', 's3', 's4', 's5']);
});
it('only accepts a drop inside the same box', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.draggedTabId = 's1';
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(false);
expect(app._isTabDropAcrossGroups(tab('s2'))).toBe(true);
});
it('lets named groups in the vertical rail win', () => {
const app = makeApp();
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
app.tabLayout = {
version: 1,
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
ungrouped: [],
};
app._fullRenderSessionTabs();
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
expect(container().classList.contains('tabs-clusters')).toBe(false);
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
});
it('switching back to classic leaves no boxes and no name splits', () => {
const app = makeApp();
app._fullRenderSessionTabs();
document.documentElement.dataset.tabArrangement = 'classic';
app._renderSessionTabsImmediate();
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
expect(container().classList.contains('tabs-clusters')).toBe(false);
});
it('draws the ledger with the classic markup and one class', () => {
makeApp('classic')._fullRenderSessionTabs();
const classic = container().innerHTML;
makeApp('ledger')._fullRenderSessionTabs();
expect(container().innerHTML).toBe(classic);
expect(container().classList.contains('tabs-ledger')).toBe(true);
expect(container().classList.contains('tabs-triage')).toBe(false);
});
});
describe('tab layouts by case and ledger (static)', () => {
const css = read('styles.css');
const mobileCss = read('mobile.css');
it('hides the case part of a name only in the clustered strip', () => {
expect(css).toMatch(/\.session-tabs\.tabs-clusters \.tab-name-case \{\s*display: none;/);
});
it('keeps the ledger to the desktop header strip', () => {
const media = css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {');
expect(media).toBeGreaterThan(-1);
// Every ledger rule lives inside that block.
const before = css.slice(0, media);
expect(before).not.toContain('.tabs-ledger');
});
it('dissolves the boxes into the one chip row on phones', () => {
expect(mobileCss).toMatch(
/:where\(\.header\) \.session-tabs-host > \.session-tabs\.tabs-clusters > \.tab-cluster \{\s*display: contents;/
);
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
});
});
+2 -2
View File
@@ -281,9 +281,9 @@ describe('vertical tab rail sort setting', () => {
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
// A sort flip leaves orientation on 'vertical' both times, so it has to
// count as a change of its own or nothing re-renders. The same holds for a
// grouping flip (tabGrouping, test/tab-triage.test.ts).
// tab layout or state order flip (test/tab-triage.test.ts).
expect(settingsUi).toContain(
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;'
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
);
});
});
+65 -18
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Tab grouping by state (`tabGrouping: 'state'`, Discussion #426
* @fileoverview Tab grouping by state (`tabArrangement: 'state'`, Discussion #426
* option C): the pure layout in constants.js and the render paths in app.js.
*
* What is pinned, and why it matters:
@@ -12,7 +12,7 @@
* - A state change is an INCREMENTAL pass: the tab element survives, only its
* `order` and the heading counts move, and a group that empties loses its
* heading.
* - `tabGrouping: 'none'` leaves no trace (no headings, no inline order, no
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
* class), and named groups in the vertical rail win over it.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
@@ -116,6 +116,24 @@ describe('CodemanTabTriage (pure)', () => {
expect(out.webOrder.get('w2')).toBeLessThan(out.groups[0].breakOrder);
});
it('turns the groups the other way up with reverse, rows unchanged inside them', () => {
const rows = [
{ id: 'a', state: 'idle', pos: 0 },
{ id: 'b', state: 'needs', pos: 1 },
{ id: 'c', state: 'working', pos: 2 },
{ id: 'd', state: 'working', pos: 3 },
];
const out = triage.layout(rows, ['w1'], { reverse: true });
expect(out.groups.map((g) => g.key)).toEqual(['idle', 'working', 'needs']);
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
expect(sorted).toEqual(['a', 'c', 'd', 'b']);
// Web tabs still close the idle group, which now comes first.
expect(out.webOrder.get('w1')).toBeLessThan(out.order.get('c')!);
for (let i = 1; i < out.groups.length; i++) {
expect(out.groups[i].headOrder).toBeGreaterThan(out.groups[i - 1].breakOrder);
}
});
it('shows the idle group for web tabs alone, and nothing for nothing', () => {
expect(triage.layout([], ['w1']).groups.map((g) => g.key)).toEqual(['idle']);
expect(triage.layout([], []).groups).toEqual([]);
@@ -158,12 +176,13 @@ describe('tab grouping in the render paths (app.js)', () => {
CodemanApp = window.__TriageCodemanApp;
});
function makeApp(grouping: 'state' | 'none' = 'state') {
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'state') {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
const root = document.documentElement;
root.setAttribute('data-tab-orientation', 'horizontal');
root.dataset.tabRailSort = 'activity';
root.dataset.tabGrouping = grouping;
root.dataset.tabArrangement = arrangement;
delete root.dataset.tabStateOrder;
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
@@ -246,6 +265,24 @@ describe('tab grouping in the render paths (app.js)', () => {
}
});
it("puts needs you in the bottom row with tabStateOrder 'urgent-last'", () => {
const app = makeApp();
document.documentElement.dataset.tabStateOrder = 'urgent-last';
app._fullRenderSessionTabs();
expect(heads()).toEqual(['Idle:1', 'Working:2', 'Waiting:1', 'Needs you:1']);
expect(visual()).toEqual([
'head:idle',
'web:w1',
'head:working',
's2',
's4',
'head:waiting',
's1',
'head:needs',
's3',
]);
});
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
makeApp()._fullRenderSessionTabs();
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
@@ -289,8 +326,8 @@ describe('tab grouping in the render paths (app.js)', () => {
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
});
it("leaves no trace with tabGrouping 'none'", () => {
makeApp('none')._fullRenderSessionTabs();
it("leaves no trace with tabArrangement 'classic'", () => {
makeApp('classic')._fullRenderSessionTabs();
expect(container().classList.contains('tabs-triage')).toBe(false);
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
expect(container().innerHTML).not.toContain('order:');
@@ -300,7 +337,7 @@ describe('tab grouping in the render paths (app.js)', () => {
it('switching off removes the headings and every inline order it wrote', () => {
const app = makeApp();
app._fullRenderSessionTabs();
document.documentElement.dataset.tabGrouping = 'none';
document.documentElement.dataset.tabArrangement = 'classic';
app._renderSessionTabsImmediate();
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
for (const el of container().querySelectorAll<HTMLElement>('.session-tab')) expect(el.style.order).toBe('');
@@ -345,12 +382,12 @@ describe('tab grouping in the render paths (app.js)', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.draggedTabId = 's2';
expect(app._isTabDropAcrossTriageGroups(tab('s4'))).toBe(false);
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(true);
expect(app._isTabDropAcrossTriageGroups(tab('s3'))).toBe(true);
document.documentElement.dataset.tabGrouping = 'none';
expect(app._isTabDropAcrossGroups(tab('s4'))).toBe(false);
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(true);
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(true);
document.documentElement.dataset.tabArrangement = 'classic';
app._fullRenderSessionTabs();
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(false);
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
});
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
@@ -367,14 +404,24 @@ describe('tab grouping wiring (static)', () => {
const css = read('styles.css');
const mobileCss = read('mobile.css');
it('stamps data-tab-grouping before first paint, defaulting to state', () => {
expect(html).toContain("dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state'");
// The catch branch (localStorage threw) must set it too.
expect(html).toContain("document.documentElement.dataset.tabGrouping='state';");
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
expect(html).toContain(
"dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'"
);
expect(html).toContain("dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first'");
// The catch branch (localStorage threw) must set both too.
expect(html).toContain(
"document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';"
);
});
it('offers the setting with "By state" as the default', () => {
expect(html).toMatch(/<select id="appSettingsTabGrouping"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="none">/);
it('offers the four layouts with "By state" as the default, and the state order', () => {
expect(html).toMatch(
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(as before\)<\/option>/
);
expect(html).toMatch(
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
);
});
it('only shows row breaks in a wrapping header strip', () => {