Files
Codeman/test/tab-triage.test.ts
T
Codeman maintainer 1568e489eb fix(tabs): never size the state label column from hidden headings
#538's by-state header strip lines its labels up in a column measured into
--tab-triage-gutter by _sizeTabTriageGutter(). Under 600px mobile.css hides
the headings (the phone row keeps its chips, no labels), but the sizer
measured them anyway: every part was 0 wide, yet `width + 5 * (parts - 1)`
made each labelled heading 5, so the "nothing to size" guard never tripped,
the column became 15px and the group key was cached as if measured. A phone
turned to landscape (>= 768px) or a foldable opened (Find N5: folded under
600, unfolded 1124 wide) then crossed into the wrapping strip with no tab
render behind it (the resize handler only calls updateTabOverflowMode()),
the key had not changed, and "WAITING 2" sat in a 15px column on top of the
first tab of its row.

Only the wrapping strip reads the column, so the sizer now measures nothing
and forgets its key while the strip does not wrap, counts only parts that
are laid out (a hidden heading sizes and keys nothing), and
updateTabOverflowMode() calls it right after deciding the wrap. Phones and
tablets therefore never measure (no layout read per render pass), and every
flip into the wrapping strip, the 768px breakpoint included, measures
afresh. The 600px breakpoint only matters below 768px, where nothing is
measured.

Tests in test/tab-triage.test.ts pin that hidden headings size and key
nothing, that the real updateTabOverflowMode() measures on the unfold with
no render, and that wrapping again re-measures with the counts unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:06 +02:00

704 lines
32 KiB
TypeScript

/**
* @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.
* - In the phone/tablet strip (one scrolling row) the ACTIVE tab changing band
* is revealed, since its chip moves while scrollLeft stays; another tab
* changing band never moves the strip (#257's browse-the-far-end rule).
* - The desktop label column (`--tab-triage-gutter`) is never sized or cached
* from headings that are not laid out (mobile.css hides them under 600px),
* and is measured again whenever the strip starts wrapping, which a phone
* turned to landscape or a foldable opened does with no tab render behind 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 { afterEach, 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);
});
describe('keeping the active tab in view in the scrolling row (phones, tablets)', () => {
// The phone and tablet strip is ONE horizontally scrolling row, and a state
// change moves a tab to another band while scrollLeft stays put. The active
// tab moving band is revealed; anything else moving must not yank the strip.
function setup() {
const app = makeApp();
const reveal = vi.fn();
app._scrollActiveTabIntoView = reveal;
app._fullRenderSessionTabs();
reveal.mockClear();
return { app, reveal };
}
it('reveals the active tab when it changes state band on an incremental pass', () => {
const { app, reveal } = setup();
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
// s2 is active and working; its turn ends.
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
expect(fullRender).not.toHaveBeenCalled();
expect(reveal).toHaveBeenCalledTimes(1);
expect(reveal.mock.calls[0][0]).toBe('s2');
// And again when a permission prompt sends it to the front.
app.pendingHooks.set('s2', new Set(['permission_prompt']));
app._renderSessionTabsImmediate();
expect(reveal).toHaveBeenCalledTimes(2);
// A pass that changes nothing reveals nothing.
app._renderSessionTabsImmediate();
expect(reveal).toHaveBeenCalledTimes(2);
});
it('leaves the strip alone when another tab changes band, even one that shifts the active tab', () => {
const { app, reveal } = setup();
// s4 leaves the working row.
app.sessions.get('s4').status = 'idle';
app._renderSessionTabsImmediate();
// s1 joins the working row AHEAD of the active s2, so s2's own order value
// changes while its band does not.
const before = orderOf(tab('s2'));
app.pendingHooks.delete('s1');
app.sessions.get('s1').status = 'busy';
app._renderSessionTabsImmediate();
expect(orderOf(tab('s2'))).not.toBe(before);
expect(reveal).not.toHaveBeenCalled();
});
it('counts a band change in the pass right after a switch (viewing a waiting tab)', () => {
const { app, reveal } = setup();
// Switch to s1, which is waiting; looking at it spends the alert.
app.activeSessionId = 's1';
app._updateActiveTabImmediate('s1');
expect(reveal).toHaveBeenCalledTimes(1);
app.pendingHooks.delete('s1');
app._renderSessionTabsImmediate();
expect(reveal).toHaveBeenCalledTimes(2);
expect(reveal.mock.calls[1][0]).toBe('s1');
});
it('reveals it when the same pass falls through to a full rebuild', () => {
const { app, reveal } = setup();
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
// A task badge appearing forces the rebuild, mid-loop.
app.sessions.get('s2').status = 'idle';
app.sessions.get('s2').taskStats = { running: 1, total: 1 };
app._renderSessionTabsImmediate();
expect(fullRender).toHaveBeenCalled();
expect(reveal).toHaveBeenCalledTimes(1);
expect(reveal.mock.calls[0][0]).toBe('s2');
});
it('does nothing in a wrapping strip or a vertical list', () => {
const { app, reveal } = setup();
container().classList.add('tabs-auto-wrap');
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
container().classList.remove('tabs-auto-wrap');
container().classList.add('tabs-two-rows');
app.sessions.get('s2').status = 'busy';
app._renderSessionTabsImmediate();
container().classList.remove('tabs-two-rows');
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
expect(reveal).not.toHaveBeenCalled();
});
it('does nothing while a web tab holds the highlight', () => {
const { app, reveal } = setup();
app.activeWebviewId = 'w1';
app._renderSessionTabsImmediate();
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
expect(reveal).not.toHaveBeenCalled();
});
});
describe('sizing the label column (--tab-triage-gutter)', () => {
// JSDOM lays nothing out, so every heading measures as hidden (0 wide) until
// `layHeadings()` gives its label and count a width, the way a desktop
// browser would once mobile.css stops hiding them.
const gutter = () => container().style.getPropertyValue('--tab-triage-gutter');
function layHeadings(widths: { label: number; count: number } | null) {
for (const head of container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')) {
for (const part of head.children) {
const width = !widths ? 0 : part.classList.contains('tab-triage-label') ? widths.label : widths.count;
(part as HTMLElement).getBoundingClientRect = () => ({ width }) as DOMRect;
}
}
}
let realDeviceType: () => string;
beforeEach(() => {
realDeviceType = window.MobileDetection.getDeviceType;
});
afterEach(() => {
window.MobileDetection.getDeviceType = realDeviceType;
});
/** An app whose updateTabOverflowMode() is the real one, as the resize handler calls it. */
function liveOverflowApp(deviceType: string) {
const app = makeApp();
app.updateTabOverflowMode = CodemanApp.prototype.updateTabOverflowMode;
app.loadAppSettingsFromStorage = () => ({});
app.getDefaultSettings = () => ({});
window.MobileDetection.getDeviceType = () => deviceType;
return app;
}
it('never sizes or keys the column from hidden headings', () => {
const app = makeApp();
app._fullRenderSessionTabs();
// The phone row: not wrapping, headings hidden.
expect(gutter()).toBe('');
expect(app._tabTriageGutterKey).toBeNull();
// Even in a wrapping strip, headings that are not laid out size nothing.
// They used to: 5px of gap per label + count made a 15px column, cached.
container().classList.add('tabs-auto-wrap');
app._sizeTabTriageGutter(container(), app._lastTabTriage);
expect(gutter()).toBe('');
expect(app._tabTriageGutterKey).toBeNull();
// Laid out, the widest label (+ the gap + 10px) sizes it and is cached.
layHeadings({ label: 50, count: 8 });
app._sizeTabTriageGutter(container(), app._lastTabTriage);
expect(gutter()).toBe('73px');
expect(app._tabTriageGutterKey).not.toBeNull();
});
it('measures when a phone turns to landscape or a foldable opens, with no tab render', () => {
const app = liveOverflowApp('mobile');
app._fullRenderSessionTabs();
app.updateTabOverflowMode();
expect(container().classList.contains('tabs-auto-wrap')).toBe(false);
expect(gutter()).toBe('');
// Unfolded: mobile.css no longer hides the headings, and the resize
// handler's updateTabOverflowMode() is the only call that arrives.
layHeadings({ label: 50, count: 8 });
window.MobileDetection.getDeviceType = () => 'desktop';
app.updateTabOverflowMode();
expect(container().classList.contains('tabs-auto-wrap')).toBe(true);
expect(gutter()).toBe('73px');
});
it('measures afresh after the strip stopped wrapping and starts again, counts unchanged', () => {
const app = liveOverflowApp('desktop');
layHeadings({ label: 50, count: 8 });
app._fullRenderSessionTabs();
layHeadings({ label: 50, count: 8 });
app.updateTabOverflowMode();
expect(gutter()).toBe('73px');
// Folded: the strip stops wrapping and the measurement is forgotten.
layHeadings(null);
window.MobileDetection.getDeviceType = () => 'mobile';
app.updateTabOverflowMode();
expect(container().classList.contains('tabs-auto-wrap')).toBe(false);
expect(app._tabTriageGutterKey).toBeNull();
// Unfolded again with wider labels (a font, a skin): re-measured, even
// though no group or count changed.
layHeadings({ label: 60, count: 8 });
window.MobileDetection.getDeviceType = () => 'desktop';
app.updateTabOverflowMode();
expect(gutter()).toBe('83px');
});
});
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('spaces the rows with item margins, so a row break costs no second gap', () => {
// Each break is a zero-height flex line of its own. With a row-gap, a group
// boundary cost two gaps (12px each with lineage lines, 54px row pitch
// instead of 42), and a negative margin on the break cannot win one back,
// because a flex line's cross size is clamped at zero.
const strip = css.indexOf('.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {');
const block = css.slice(strip, css.indexOf('}', strip));
expect(block).toContain('row-gap: 0;');
expect(block).toContain('padding-bottom: 0;');
expect(css).toMatch(
/\.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\) > :not\(\.tab-triage-break\) \{\s*margin-bottom: var\(--lineage-row-gap, 4px\);/
);
// With lineage the last row's margin is part of the bottom routing room.
expect(css).toMatch(
/\.session-tabs\.tabs-triage\.lineage-tree:is\(\.tabs-auto-wrap, \.tabs-two-rows\) \{[^}]*padding-bottom: calc\(var\(--lineage-bottom-room\) - var\(--lineage-row-gap\)\);/
);
});
it('opens the lineage spine channel between the label column and the tabs', () => {
// The gutter padding used to replace the lineage channel, so the spine ran
// through every label. With lineage lines, the strip pads by both, every label
// keeps its column at the edge and gives the channel back after itself, and
// the first row's label still starts right after the brand.
const lineage = '.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows)';
const rule = (selector: string) => {
const start = css.indexOf(`${selector} {`);
expect(start, selector).toBeGreaterThan(-1);
return css.slice(start, css.indexOf('}', start));
};
expect(rule(lineage)).toContain(
'padding-left: calc(var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel));'
);
const head = rule(`${lineage} > .tab-triage-head`);
expect(head).toContain('margin-left: calc(-1 * (var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel)));');
expect(head).toContain('margin-right: var(--lineage-spine-channel);');
const lead = rule(`${lineage} > .tab-triage-head--lead`);
expect(lead).toContain(
'margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px) - var(--lineage-spine-channel));'
);
expect(lead).toContain('margin-right: 0;');
// The lead rule must come after the head rule it overrides (equal specificity).
expect(css.indexOf(`${lineage} > .tab-triage-head--lead {`)).toBeGreaterThan(
css.indexOf(`${lineage} > .tab-triage-head {`)
);
// session-lineage.js reads the channel's position back from that padding.
expect(read('session-lineage.js')).toContain("getPropertyValue('--lineage-spine-channel')");
});
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;/);
});
});