mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge #538: Tab Layout (by state, by case, ledger, classic) and three header stats styles (Discussion #426)
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
|
||||
* option G): 'classic' (as before), 'compact' (one system pill with rings
|
||||
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
|
||||
* default).
|
||||
*
|
||||
* Pinned here:
|
||||
* - The default is 'tiles', and only the three known values are honoured.
|
||||
* - The two clustered styles move the connection indicator INTO the system
|
||||
* stats pill and the plan chip right after it, on the REAL header markup
|
||||
* from index.html, and 'classic' puts both back exactly where the template
|
||||
* had them.
|
||||
* - The WS readout never disappears with a hidden System Stats pill.
|
||||
* - The parts only the new styles draw (stat rings, plan rings, meters, tile words)
|
||||
* are rendered with sane values and hidden by default in CSS, which is what
|
||||
* keeps 'classic' looking exactly as before.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const HEADER = INDEX.slice(INDEX.indexOf('<header class="header">'), INDEX.indexOf('</header>') + '</header>'.length);
|
||||
|
||||
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.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => "desktop" }, ' +
|
||||
'KeyboardHandler = {}, SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('settings-ui.js') +
|
||||
'\n' +
|
||||
read('panels-ui.js') +
|
||||
'\n;window.__HeaderStatsCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__HeaderStatsCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp() {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Element children of the header's right side, by id (or first class). */
|
||||
const rightSide = () =>
|
||||
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
|
||||
const statsChildren = () =>
|
||||
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = HEADER;
|
||||
delete document.documentElement.dataset.headerStats;
|
||||
});
|
||||
|
||||
describe('resolveHeaderStatsStyle', () => {
|
||||
it("defaults to 'tiles' and honours only the three known styles", () => {
|
||||
const app = makeApp();
|
||||
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHeaderStatsStyle', () => {
|
||||
it('clusters WS, the system stats and the plan chip for tiles and compact', () => {
|
||||
for (const style of ['tiles', 'compact']) {
|
||||
document.body.innerHTML = HEADER;
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style, showSystemStats: true });
|
||||
expect(document.documentElement.dataset.headerStats).toBe(style);
|
||||
expect(statsChildren()[0]).toBe('connectionIndicator');
|
||||
const right = rightSide();
|
||||
expect(right[right.indexOf('headerSystemStats') + 1]).toBe('planUsageChip');
|
||||
expect(right).not.toContain('connectionIndicator');
|
||||
}
|
||||
});
|
||||
|
||||
it("'classic' puts both back exactly where the template had them", () => {
|
||||
const template = rightSide();
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'compact', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'classic', showSystemStats: true });
|
||||
expect(rightSide()).toEqual(template);
|
||||
expect(statsChildren()).not.toContain('connectionIndicator');
|
||||
expect(document.documentElement.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
|
||||
it('keeps WS out of a hidden System Stats pill', () => {
|
||||
const template = rightSide();
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: false });
|
||||
expect(statsChildren()).not.toContain('connectionIndicator');
|
||||
expect(rightSide().indexOf('connectionIndicator')).toBe(template.indexOf('connectionIndicator'));
|
||||
});
|
||||
|
||||
it('is idempotent: re-applying the same style moves nothing', () => {
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
const once = document.getElementById('headerRight')!.innerHTML;
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
expect(document.getElementById('headerRight')!.innerHTML).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the parts the new styles draw', () => {
|
||||
it('gives every plan window a ring and a meter with a clamped fill, the label the real number', () => {
|
||||
const app = makeApp();
|
||||
app.updatePlanUsageChip({ fiveHour: { usedPercentage: 28 }, sevenDay: { usedPercentage: 104 } });
|
||||
const wins = [...document.querySelectorAll<HTMLElement>('#planUsageChip .pu-win')];
|
||||
expect(wins).toHaveLength(2);
|
||||
expect(wins[0].querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('28');
|
||||
expect(wins[0].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('28%');
|
||||
expect(wins[0].querySelector('.pu-ring')!.classList.contains('pu-green')).toBe(true);
|
||||
expect(wins[1].querySelector('.pu-val')!.textContent).toBe('104%');
|
||||
expect(wins[1].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('100%');
|
||||
expect(wins[1].querySelector('.pu-meter')!.classList.contains('pu-red')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an idle Claude window as a dimmed slot with an empty ring and meter', () => {
|
||||
const app = makeApp();
|
||||
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 35 } });
|
||||
const idle = document.querySelector<HTMLElement>('#planUsageChip .pu-win-idle')!;
|
||||
expect(idle.querySelector('.pu-val')!.textContent).toBe('—');
|
||||
expect(idle.querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('0');
|
||||
expect(idle.querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('0%');
|
||||
});
|
||||
|
||||
it('names the connection state in two short words per dot state', () => {
|
||||
const app = makeApp();
|
||||
const words = (dotClass: string, text = '') => app._connectionTileWords({ dotClass, text });
|
||||
expect(words('connection-dot connected', 'WS')).toMatchObject({ label: 'WS', value: 'live', state: 'connected' });
|
||||
expect(words('connection-dot fallback', 'HTTP')).toMatchObject({ label: 'HTTP', value: 'fallback' });
|
||||
expect(words('connection-dot offline', 'Offline')).toMatchObject({ label: 'NET', value: 'offline' });
|
||||
expect(words('connection-dot draining', 'Sending...')).toMatchObject({ label: 'SEND', value: 'queued' });
|
||||
expect(words('connection-dot reconnecting', 'WS…')).toMatchObject({ label: 'WS', value: 'retry' });
|
||||
expect(words('connection-dot reconnecting', 'Reconnecting...')).toMatchObject({ label: 'SSE', value: 'retry' });
|
||||
});
|
||||
|
||||
it('writes the tile words beside the classic text, which stays as it was', () => {
|
||||
const app = makeApp();
|
||||
app._computeConnectionDescriptor = () => ({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS · 2.0KB queued',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
app._updateConnectionIndicator();
|
||||
expect(document.getElementById('connectionText')!.textContent).toBe('WS · 2.0KB queued');
|
||||
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
|
||||
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
|
||||
});
|
||||
|
||||
it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => {
|
||||
const app = makeApp();
|
||||
const ring = (id: string) => {
|
||||
const el = document.getElementById(id)!;
|
||||
return [el.style.getPropertyValue('--pu'), el.classList.contains('high')];
|
||||
};
|
||||
app.updateSystemStatsDisplay({ cpu: 22, memory: { usedMB: 14.4 * 1024, percent: 45.6 } });
|
||||
expect(ring('statCpuRing')).toEqual(['22', false]);
|
||||
expect(ring('statMemRing')).toEqual(['46', false]);
|
||||
app.updateSystemStatsDisplay({ cpu: 140, memory: { usedMB: 30 * 1024, percent: 81 } });
|
||||
expect(ring('statCpuRing')).toEqual(['100', true]);
|
||||
expect(ring('statMemRing')).toEqual(['81', true]);
|
||||
// A ring comes first in its stat, before the label, like the plan rings.
|
||||
expect(document.getElementById('statCpuRing')!.nextElementSibling!.className).toBe('stat-label');
|
||||
});
|
||||
});
|
||||
|
||||
describe('header stats wiring (static)', () => {
|
||||
const css = read('styles.css');
|
||||
|
||||
it('hides the new-style parts by default, so classic looks exactly as before', () => {
|
||||
expect(css).toMatch(
|
||||
/\.stat-ring,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
);
|
||||
});
|
||||
|
||||
it('lays a tile out as three rows, so its height never depends on the font', () => {
|
||||
// A bar laid over the bottom of a fixed-height tile is what let a taller
|
||||
// system mono (SF Mono) push the value into it.
|
||||
expect(css).toMatch(/grid-template-rows: 9px 14px 2px;\s*row-gap: 2px;/);
|
||||
const tiles = css.slice(css.indexOf('/* --- Tiles: label over value'));
|
||||
const bar = tiles.slice(tiles.indexOf("html[data-header-stats='tiles'] .header-system-stats .stat-bar,"));
|
||||
expect(bar.slice(0, bar.indexOf('}'))).not.toContain('position: absolute');
|
||||
expect(bar.slice(0, bar.indexOf('}'))).toContain('grid-row: 3;');
|
||||
});
|
||||
|
||||
it('gives the header buttons the tile box beside the tiles', () => {
|
||||
expect(css).toMatch(
|
||||
/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header \{\s*width: 36px;\s*height: 36px;/
|
||||
);
|
||||
expect(css).toMatch(/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header > svg \{\s*width: 18px;/);
|
||||
});
|
||||
|
||||
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
|
||||
expect(INDEX).toContain(
|
||||
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the three styles with tiles marked as the default', () => {
|
||||
expect(INDEX).toMatch(
|
||||
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @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('keeps every ledger row one height and makes the active cell stand out', () => {
|
||||
const ledger = css.slice(css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {'));
|
||||
expect(ledger).toContain('align-items: stretch;');
|
||||
expect(ledger).toContain('min-height: 30px;');
|
||||
expect(ledger).toMatch(
|
||||
/\.tabs-ledger > \.session-tab\.active \{[^}]*box-shadow: inset 0 0 0 1px var\(--accent\), inset 4px 0 0 var\(--accent\) !important;/
|
||||
);
|
||||
});
|
||||
|
||||
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;/);
|
||||
});
|
||||
});
|
||||
+11
-10
@@ -205,10 +205,11 @@ describe('vertical tab rail row order', () => {
|
||||
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => {
|
||||
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
|
||||
// moves the card — so the badge names a shortcut, not a row position.
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
|
||||
);
|
||||
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
|
||||
// `listOrder` is the rail sort's map, or the state grouping's when that is
|
||||
// on (test/tab-triage.test.ts); either way it only sets the inline order.
|
||||
expect(appJs).toContain('const liveIds = tabOrder.filter((id) => this.sessions.has(id));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain('` style="order:${listOrder.get(id)}"`');
|
||||
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
|
||||
// The loop itself still walks the user's order, which is what makes the
|
||||
// badge, drag-and-drop and the arrow-key walk agree with each other.
|
||||
@@ -219,12 +220,11 @@ describe('vertical tab rail row order', () => {
|
||||
// A session going working→idle never adds or removes a tab, so the full
|
||||
// rebuild is not reached — and a rebuild here would restart every card's
|
||||
// animation on every SSE tick anyway.
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
|
||||
);
|
||||
expect(appJs).toContain('const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;');
|
||||
// An empty string is what clears the property when the rail stops sorting.
|
||||
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';");
|
||||
expect(appJs).toContain("const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';");
|
||||
});
|
||||
|
||||
it('drops the drag affordance while sorting, so a card cannot snap back', () => {
|
||||
@@ -280,9 +280,10 @@ describe('vertical tab rail sort setting', () => {
|
||||
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
|
||||
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.
|
||||
// count as a change of its own or nothing re-renders. The same holds for a
|
||||
// tab layout or state order flip (test/tab-triage.test.ts).
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* @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:
|
||||
* - The four groups and their order (needs you, waiting, working, idle) and
|
||||
* which home-screen state lands in which, including the two folds that are
|
||||
* easy to get wrong: a failed session joins "needs you", and an agent that
|
||||
* exited inside a live pane (#446) is idle even while its status says busy.
|
||||
* - It is applied as flex `order` only. The DOM stays in tab order, so Alt+N
|
||||
* badges, drag and the keyboard walk keep reading the list they always read.
|
||||
* - 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.
|
||||
* - `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
|
||||
* `document` below is that window's.
|
||||
*
|
||||
* 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 Row = { id: string; state: string; exited?: boolean; pos?: number };
|
||||
type Layout = {
|
||||
order: Map<string, number>;
|
||||
webOrder: Map<string, number>;
|
||||
groups: Array<{ key: string; label: string; count: number; headOrder: number; breakOrder: number }>;
|
||||
};
|
||||
type Triage = {
|
||||
GROUPS: Array<{ key: string; label: string }>;
|
||||
STRIDE: number;
|
||||
groupFor: (state: string, exited: boolean) => string;
|
||||
layout: (rows: Row[], webviewIds?: string[]) => Layout;
|
||||
};
|
||||
|
||||
function loadTriage(): Triage {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabTriage: Triage }).CodemanTabTriage;
|
||||
}
|
||||
|
||||
describe('CodemanTabTriage (pure)', () => {
|
||||
const triage = loadTriage();
|
||||
|
||||
it('has four groups, most urgent first', () => {
|
||||
expect(triage.GROUPS.map((g) => g.key)).toEqual(['needs', 'waiting', 'working', 'idle']);
|
||||
expect(triage.GROUPS.map((g) => g.label)).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']);
|
||||
// Only idle is quiet: it keeps a heading element but draws no label.
|
||||
expect(triage.GROUPS.filter((g) => (g as { quiet?: boolean }).quiet).map((g) => g.key)).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('folds the six home-screen states into the four groups', () => {
|
||||
expect(triage.groupFor('needs', false)).toBe('needs');
|
||||
expect(triage.groupFor('error', false)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', false)).toBe('waiting');
|
||||
expect(triage.groupFor('working', false)).toBe('working');
|
||||
expect(triage.groupFor('idle', false)).toBe('idle');
|
||||
expect(triage.groupFor('done', false)).toBe('idle');
|
||||
expect(triage.groupFor('something-new', false)).toBe('idle');
|
||||
});
|
||||
|
||||
it('puts an exited agent with idle, never with working', () => {
|
||||
expect(triage.groupFor('working', true)).toBe('idle');
|
||||
expect(triage.groupFor('idle', true)).toBe('idle');
|
||||
// A human being blocked still outranks the agent having exited.
|
||||
expect(triage.groupFor('needs', true)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', true)).toBe('waiting');
|
||||
});
|
||||
|
||||
it('gives each group a band of order values and lists only non-empty groups', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'idle', pos: 2 },
|
||||
]);
|
||||
expect(out.groups.map((g) => [g.key, g.count])).toEqual([
|
||||
['needs', 1],
|
||||
['idle', 2],
|
||||
]);
|
||||
const needs = out.groups[0];
|
||||
const idle = out.groups[1];
|
||||
expect(out.order.get('b')).toBeGreaterThan(needs.headOrder);
|
||||
expect(out.order.get('b')).toBeLessThan(needs.breakOrder);
|
||||
// Rows keep their relative position inside a group.
|
||||
expect(out.order.get('a')).toBeLessThan(out.order.get('c')!);
|
||||
expect(out.order.get('a')).toBeGreaterThan(idle.headOrder);
|
||||
// A heading opens its band, the break closes it, and bands never overlap.
|
||||
expect(needs.breakOrder).toBeLessThan(idle.headOrder);
|
||||
expect(Math.floor(out.order.get('b')! / triage.STRIDE)).toBe(Math.floor(needs.headOrder / triage.STRIDE));
|
||||
});
|
||||
|
||||
it('ranks rows inside a group by `pos`, stably', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'x', state: 'working', pos: 5 },
|
||||
{ id: 'y', state: 'working', pos: 1 },
|
||||
{ id: 'z', state: 'working', pos: 1 },
|
||||
]);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['y', 'z', 'x']);
|
||||
});
|
||||
|
||||
it('closes the idle group with the web tabs and counts them', () => {
|
||||
const out = triage.layout([{ id: 's', state: 'idle', pos: 0 }], ['w1', 'w2']);
|
||||
expect(out.groups).toHaveLength(1);
|
||||
expect(out.groups[0]).toMatchObject({ key: 'idle', count: 3 });
|
||||
expect(out.webOrder.get('w1')).toBeGreaterThan(out.order.get('s')!);
|
||||
expect(out.webOrder.get('w2')).toBeGreaterThan(out.webOrder.get('w1')!);
|
||||
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([]);
|
||||
expect(triage.layout(undefined as unknown as Row[], undefined).groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping in the render paths (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.__TriageCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__TriageCodemanApp;
|
||||
});
|
||||
|
||||
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.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([
|
||||
['s1', { id: 's1', name: 'w1-alpha', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'w2-beta', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'w3-gamma', status: 'idle' }],
|
||||
['s4', { id: 's4', name: 'w4-delta', status: 'busy' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4'];
|
||||
app.pendingHooks = new Map([
|
||||
['s3', new Set(['permission_prompt'])],
|
||||
['s1', new Set(['idle_prompt'])],
|
||||
]);
|
||||
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 orderOf = (el: HTMLElement) => Number(el.style.order);
|
||||
/** What the eye sees: every ordered child, by `order`, as `head:<key>` / `<id>`. */
|
||||
const visual = () =>
|
||||
[...container().children]
|
||||
.filter((el) => !(el as HTMLElement).classList.contains('tab-triage-break'))
|
||||
.sort((a, b) => orderOf(a as HTMLElement) - orderOf(b as HTMLElement))
|
||||
.map((el) => {
|
||||
const h = el as HTMLElement;
|
||||
if (h.classList.contains('tab-triage-head')) return `head:${h.dataset.triageGroup}`;
|
||||
return h.dataset.webviewId ? `web:${h.dataset.webviewId}` : h.dataset.id!;
|
||||
});
|
||||
const heads = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')]
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
.map((h) =>
|
||||
h.classList.contains('tab-triage-head--quiet')
|
||||
? `${h.dataset.triageGroup}:quiet`
|
||||
: `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('draws a heading per non-empty state and places every tab in its group', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(true);
|
||||
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'idle:quiet']);
|
||||
// The idle row keeps its heading as an anchor, with nothing drawn in it.
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="idle"]')!.textContent).toBe('');
|
||||
expect(visual()).toEqual([
|
||||
'head:needs',
|
||||
's3',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
]);
|
||||
// One row break per heading; headings and breaks stay out of the tablist.
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-break')).toHaveLength(4);
|
||||
for (const el of container().querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')) {
|
||||
expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
});
|
||||
|
||||
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:quiet', '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('marks the first row heading as the lead, in either state order', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const lead = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head--lead')].map((h) => h.dataset.triageGroup);
|
||||
expect(lead()).toEqual(['needs']);
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(lead()).toEqual(['idle']);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(domOrder).toEqual(['s1', 's2', 's3', 's4']);
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('moves a tab between groups on an incremental pass, without rebuilding it', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s2 = tab('s2');
|
||||
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
// s2 finishes its turn; the dialog on s3 is answered.
|
||||
app.sessions.get('s2').status = 'idle';
|
||||
app.pendingHooks.delete('s3');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(fullRender).not.toHaveBeenCalled();
|
||||
expect(tab('s2')).toBe(s2);
|
||||
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'idle:quiet']);
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="needs"]')).toBeNull();
|
||||
expect(container().querySelector('.tab-triage-break[data-triage-group="needs"]')).toBeNull();
|
||||
expect(visual()).toEqual(['head:waiting', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('reconciles headings in place: an unchanged pass rewrites nothing', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app._renderSessionTabsImmediate();
|
||||
const head = container().querySelector('.tab-triage-head[data-triage-group="working"]');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="working"]')).toBe(head);
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-head')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('files an agent that exited in a live pane under idle, a failed session under needs you', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').paneExit = { at: 1, status: 0 };
|
||||
app.sessions.get('s1').status = 'error';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
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:');
|
||||
expect(container().querySelector<HTMLElement>('.session-tab[data-webview-id]')!.style.order).toBe('');
|
||||
});
|
||||
|
||||
it('switching off removes the headings and every inline order it wrote', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
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('');
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
|
||||
it('ranks rows inside each section by the activity sort on a sorted rail', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.pendingHooks = new Map();
|
||||
// Both working; s4 has been running longer, so the activity sort puts it first.
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's4', 's2', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('keeps tab order inside each row on the header strip', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's2', 's4', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
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-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a drop into another group and allows one inside the same group', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's2';
|
||||
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._isTabDropAcrossGroups(tab('s1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
|
||||
const app = makeApp();
|
||||
app._mobileOverviewState = undefined;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping wiring (static)', () => {
|
||||
const html = read('index.html');
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
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 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', () => {
|
||||
expect(css).toMatch(/\.tab-triage-break \{\s*display: none;/);
|
||||
expect(css).toContain(
|
||||
'.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break'
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rows after the first start under the brand, labels left-aligned', () => {
|
||||
expect(css).toMatch(
|
||||
/\.header:has\(> \.session-tabs-host > \.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\)\) > \.header-brand \{\s*position: absolute;/
|
||||
);
|
||||
expect(css).toMatch(/> \.tab-triage-head \{\s*justify-content: flex-start;/);
|
||||
expect(css).toMatch(
|
||||
/> \.tab-triage-head--lead \{\s*width: auto;\s*margin-left: calc\(var\(--tab-triage-brand, 100px\) - var\(--tab-triage-gutter, 92px\)\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the headings on phones, whose strip stays one scrolling row', () => {
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user