fix(tabs): collapsed-header alerts, quieter layout reads, tree key and touch fixes (#517, #519 review)

Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.

#517 minors
- A collapsed group hid rows that need the user with no signal on its
  header. The header now takes the most urgent alert among the session
  rows its collapse hides, in the tab alert language (tab-alert-action
  red ring, tab-alert-idle yellow ring, the existing ::before rules
  extended to the header). New pure hiddenGroupAlerts() over a per-section
  `hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
  paths, since alerts change without a rebuild. The kept selection draws
  its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
  every 5 s forever. _applyTabLayout() now rebuilds only when the
  structure key changed. The key drops the layout version (bumped on
  every session create/close and order PUT) and instead carries group
  names and the rows each collapse hides, so a version bump that moves
  nothing costs nothing and a rename still rebuilds. The load coordinator
  backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
  the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
  good. A parse or shape error now reads as nothing collapsed and is
  rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
  re-ranks per group, sends no session:orderChanged and leaves this
  client's sessionOrder and Alt+N targets diverged. The move is now a
  no-op unless the neighbour is in the active session's own section
  (_canSwapActiveTabWith, reading the projection's new sectionByRef, which
  also covers rows a collapse hides). Within a group the swap still works
  and the server agrees with it; the flat rail and the strip are
  unchanged.

#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
  focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
  treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
  (a test cannot read another revision's files portably). Checked by
  hand instead: the flat strip and flat rail markup of this branch before
  and after this commit are identical in all 16 cases (both orientations,
  manual and activity sort, no layout and zero groups, full and
  incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.

#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
  the roving tabindex parks those at -1, so the [tabindex] arm of
  MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
  lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
  a row (Enter on the overflow button re-selected and reloaded the active
  session instead of reopening its menu). It now returns unless the key
  landed on the treeitem itself.

#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
  re-sorted rows on the incremental path. The position pass is extracted
  (_applyTabTreePositions) and re-run, with aria-selected and the header
  alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
  owning an empty group. A group with no open rows is now a tree leaf: no
  aria-expanded, no aria-owns, its rows container presentation; Left and
  Right do nothing on it, and its chevron keys off the section's
  collapsed class instead of aria-expanded.

Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-04 23:52:41 +02:00
parent b451b3851e
commit 7917273188
9 changed files with 670 additions and 61 deletions
+86 -2
View File
@@ -9,8 +9,9 @@
*
* DOM emulation cannot answer either question (hit testing, hover reveal and
* layout are Chromium's), which is why this runs in a real browser. The shipping
* app.js, webview-tabs.js and styles.css are loaded into a page; the CodemanApp
* instance gets stub actions that only record what ran.
* app.js, webview-tabs.js, terminal-ui.js (for the touch keyboard dismissal) and
* styles.css are loaded into a page; the CodemanApp instance gets stub actions
* that only record what ran.
*
* Port: none (page.setContent, no server).
*/
@@ -57,6 +58,7 @@ describe('tab row activation in Chromium', () => {
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
});
await page.addScriptTag({ content: read('webview-tabs.js') });
await page.addScriptTag({ content: read('terminal-ui.js') });
await page.evaluate(() => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
@@ -290,6 +292,88 @@ describe('tab row activation in Chromium', () => {
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
});
it('leaves Enter on a focused in-row control to that control in the tree', async () => {
await render('vertical', true);
// A click focuses the overflow button (the action menu hands focus back the
// same way on Escape). Enter there must run the button, not re-select the row.
await page.locator('[data-id="one"] .tab-more').click();
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
await reset();
await page.keyboard.press('Enter');
expect(await result()).toEqual({ activation: null, action: 'overflow' });
await page.keyboard.press('ArrowDown');
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
});
it('keeps the touch keyboard up when a tree header or unselected row is tapped', async () => {
await render('vertical', true);
const state = await page.evaluate(() => {
const w = window as any;
const app = w.__app;
const input = document.createElement('textarea');
input.className = 'xterm-helper-textarea';
document.body.appendChild(input);
app._installMobileKeyboardDismiss();
// A finger that lifts where it landed (no travel): the handler reads only
// `touches`, so a plain Event carrying them stands in for a TouchEvent.
const tap = (target: Element) => {
const at = (type: string, touches: Array<{ clientX: number; clientY: number }>) => {
const ev = new Event(type, { bubbles: true });
Object.defineProperty(ev, 'touches', { value: touches });
target.dispatchEvent(ev);
};
at('touchstart', [{ clientX: 10, clientY: 10 }]);
at('touchend', []);
};
const keptAfterTap = (selector: string) => {
input.focus();
tap(document.querySelector(selector)!);
return document.activeElement === input;
};
const out = {
header: keptAfterTap('[data-tab-group-header="g"] .tab-layout-group-name'),
unselectedRow: keptAfterTap('[data-webview-id="web"] .tab-name'),
inertChrome: false,
};
// Inert chrome still dismisses, so the handler is live.
out.inertChrome = !keptAfterTap('.tab-layout-ungrouped-header');
document.removeEventListener('touchstart', app._mobileKeyboardDismissStart);
document.removeEventListener('touchmove', app._mobileKeyboardDismissMove);
document.removeEventListener('touchend', app._mobileKeyboardDismissHandler);
app._mobileKeyboardDismissHandler = null;
input.remove();
return out;
});
expect(state).toEqual({ header: true, unselectedRow: true, inertChrome: true });
});
it('rings a collapsed header in the tab alert colour of the row it hides', async () => {
await render('vertical', true);
const ring = await page.evaluate(() => {
const app = (window as any).__app;
// "two" is the selection, so collapsing the group hides "one".
app.activeSessionId = 'two';
app.tabAlerts.set('one', 'action');
app.toggleTabGroupCollapsed('g', true);
const header = document.querySelector('[data-tab-group-header="g"]')!;
const before = getComputedStyle(header, '::before');
const out = {
hidden: !document.querySelector('[data-id="one"]'),
className: header.classList.contains('tab-alert-action'),
content: before.content,
borderColor: before.borderTopColor,
};
app.tabAlerts.clear();
app.toggleTabGroupCollapsed('g', false);
return out;
});
expect(ring.hidden).toBe(true);
expect(ring.className).toBe(true);
expect(ring.content).not.toBe('none');
// --red, whatever the skin resolves it to: a real colour, not transparent.
expect(ring.borderColor).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/);
});
it('keeps tab semantics and no tree roles without groups', async () => {
await render('vertical', false);
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
+145 -3
View File
@@ -104,6 +104,16 @@ describe('projection', () => {
expect(collapsed.sections[0].count).toBe(3);
expect(collapsed.sections[0].collapsed).toBe(true);
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
expect(ids(collapsed.sections[0].hidden)).toEqual(['session:s2', 'webview:w1']);
// Every placed row knows its section, shown or hidden (null = Ungrouped).
expect(collapsed.sectionByRef).toEqual({
'session:s2': 'g1',
'webview:w1': 'g1',
'session:s1': 'g1',
'session:s3': 'g2',
'webview:w2': null,
'session:s4': null,
});
// An active web tab owns the highlight even while a session stays selected.
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
@@ -117,13 +127,65 @@ describe('structure key', () => {
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
const base = h.structureKey(layout(), h.project(layout(), opts), []);
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).not.toBe(base);
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
base
);
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
const renamed = { ...layout(), groups: [{ ...layout().groups[0], name: 'Renamed' }, layout().groups[1]] };
expect(h.structureKey(renamed, h.project(renamed, opts), [])).not.toBe(base);
expect(h.structureKey(layout(), null, [])).toBeNull();
});
it('ignores a version bump that moves nothing, so a lifecycle broadcast costs no rebuild', () => {
const h = loadHelper();
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).toBe(
h.structureKey(layout(4), h.project(layout(4), opts), [])
);
});
it('changes when a collapse hides a different row, even with equal counts and shown rows', () => {
const h = loadHelper();
const two = (refs: Ref[][]) => ({
version: 1,
groups: [
{ id: 'a', name: 'A', refs: refs[0] },
{ id: 'b', name: 'B', refs: refs[1] },
],
ungrouped: [],
});
const s = (id: string): Ref => ({ kind: 'session', id });
const opts = { liveSessionIds: ['x', 'y'], openWebviewIds: [], collapsedGroupIds: ['a', 'b'] };
const before = two([[s('x')], [s('y')]]);
const after = two([[s('y')], [s('x')]]);
// Lineage anchors a hidden row to its header, so who hides where is structure.
expect(h.structureKey(after, h.project(after, opts), ['a', 'b'])).not.toBe(
h.structureKey(before, h.project(before, opts), ['a', 'b'])
);
});
});
describe('collapsed header alerts', () => {
it('reports the most urgent alert among the session rows a collapse hides', () => {
const h = loadHelper();
const alerts = new Map([
['s2', 'idle'],
['s1', 'action'],
['s3', 'idle'],
['s4', 'action'],
]);
const alertOf = (id: string) => alerts.get(id);
const common = { liveSessionIds: ['s1', 's2', 's3', 's4'], openWebviewIds: ['w1', 'w2'] };
// s1 is the kept selection, so it draws its own ring: only s2 (idle) is behind g1.
const kept = h.project(layout(), { ...common, collapsedGroupIds: ['g1', 'g2'], activeSessionId: 's1' });
expect(h.hiddenGroupAlerts(kept, alertOf)).toEqual({ g1: 'idle', g2: 'idle' });
// With s1 hidden too, the red one wins over the yellow one.
const hidden = h.project(layout(), { ...common, collapsedGroupIds: ['g1'], activeSessionId: 's4' });
expect(h.hiddenGroupAlerts(hidden, alertOf)).toEqual({ g1: 'action' });
// Expanded groups and the ungrouped section never report (their rows are on screen).
expect(h.hiddenGroupAlerts(h.project(layout(), common), alertOf)).toEqual({});
expect(h.hiddenGroupAlerts(null, alertOf)).toEqual({});
});
});
describe('per-device collapse storage', () => {
@@ -152,7 +214,7 @@ describe('per-device collapse storage', () => {
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
});
it('reports failure (all-expanded) on unreadable, malformed or unwritable storage', () => {
it('reports failure (all-expanded) on unreadable or unwritable storage', () => {
const h = loadHelper();
const throwing = {
getItem: () => {
@@ -163,9 +225,23 @@ describe('per-device collapse storage', () => {
},
};
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
expect(h.loadCollapsedGroupIds(storage('{"g1":true}'), ['g1'])).toEqual({ ids: [], ok: false });
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
});
it('reads a malformed stored value as nothing collapsed and repairs it, so collapse keeps working', () => {
const h = loadHelper();
for (const bad of ['{"g1":true}', 'not json', '"g1"', 'null']) {
const local = storage(bad);
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: [], ok: true });
expect(local.setItem, bad).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '[]');
expect(h.saveCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
}
// Without a group list to validate against, nothing is written.
const untouched = storage('{"g1":true}');
expect(h.loadCollapsedGroupIds(untouched)).toEqual({ ids: [], ok: true });
expect(untouched.setItem).not.toHaveBeenCalled();
});
});
describe('grouped markup', () => {
@@ -224,6 +300,27 @@ describe('grouped markup', () => {
});
});
describe('empty groups', () => {
it('renders a group with no open rows as a tree leaf, expanded or collapsed', () => {
const h = loadHelper();
for (const collapsedGroupIds of [[], ['g2']]) {
const projection = h.project(layout(), { liveSessionIds: ['s1', 's2'], openWebviewIds: [], collapsedGroupIds });
expect(projection.sections[1]).toMatchObject({ id: 'g2', count: 0, refs: [] });
const html = h.renderProjection(projection, () => '', escape);
const doc = new JSDOM(`<main>${html}</main>`).window.document;
const header = doc.querySelector('[data-tab-group-header="g2"]')!;
expect(header.getAttribute('role')).toBe('treeitem');
expect(header.hasAttribute('aria-expanded')).toBe(false);
expect(header.hasAttribute('aria-owns')).toBe(false);
expect(header.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
'presentation'
);
// The populated group is unaffected.
expect(doc.querySelector('[data-tab-group-header="g1"]')!.getAttribute('aria-expanded')).toBe('true');
}
});
});
describe('load coordination', () => {
it('applies only the newest response when loads overlap', async () => {
const h = loadHelper();
@@ -271,6 +368,51 @@ describe('load coordination', () => {
expect(applyLayout).toHaveBeenCalledWith(layout(7));
});
it('backs off failed retries, stops after the cap, and starts over after a success', async () => {
const h = loadHelper();
const delays: number[] = [];
let pending: (() => Promise<boolean>) | null = null;
let failing = true;
const applyFallback = vi.fn();
const coordinator = h.createLoadCoordinator({
fetchLayout: async () => {
if (failing) throw new Error('offline');
return layout(1);
},
applyLayout: vi.fn(),
applyFallback,
retryDelayMs: 5000,
maxRetryDelayMs: 30000,
maxRetries: 4,
scheduleRetry: (fn: () => Promise<boolean>, delay: number) => {
delays.push(delay);
pending = fn;
return delays.length;
},
cancelRetry: vi.fn(),
});
await coordinator.load();
// Bounded drain: an uncapped retry must fail here, not spin forever (the
// loop only awaits microtasks, so vitest's own timeout could never fire).
for (let drained = 0; pending && drained < 10; drained++) {
const next: () => Promise<boolean> = pending;
pending = null;
await next();
}
// Four retries (5 s doubling, capped at 30 s), then nothing more is scheduled.
expect(delays).toEqual([5000, 10000, 20000, 30000]);
expect(applyFallback).toHaveBeenCalledTimes(5);
// An outside load (SSE init, tab:layoutChanged) still tries, and a success
// resets the count, so the next outage gets the full schedule again.
failing = false;
expect(await coordinator.load()).toBe(true);
failing = true;
await coordinator.load();
expect(delays.at(-1)).toBe(5000);
coordinator.dispose();
});
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
const h = loadHelper();
let resolve!: (value: unknown) => void;
+218 -3
View File
@@ -5,9 +5,11 @@
* owner's groups only when it is vertical AND there is at least one group (every
* other case must be byte-for-byte the flat rail), collapse is per-device and
* keeps the active row, a structural change escapes the incremental patch path,
* drag-reorder is withheld, lineage arcs to a collapse-hidden session anchor
* to its group header, and the grouped rail (only) is an ARIA tree with one
* roving tab stop, a tree keyboard model and focus restored across rebuilds.
* drag-reorder and cross-group Ctrl+Shift moves are withheld, a collapsed
* header carries the alert it hides, layout reads rebuild only on a structural
* change and back off when they fail, lineage arcs to a collapse-hidden session
* anchor to its group header, and the grouped rail (only) is an ARIA tree with
* one roving tab stop, a tree keyboard model and focus restored across rebuilds.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
* `document`, `localStorage` and `window` below are that window's, not Node's.
@@ -17,6 +19,7 @@
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';
@@ -277,6 +280,76 @@ describe('grouped vertical rail', () => {
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
expect(app.collapsedTabGroupIds.size).toBe(0);
});
it('keeps collapse working after a malformed stored value, and repairs it', () => {
localStorage.setItem('codeman:tab-groups-collapsed', '{"group-x":true}');
const app = makeApp({ tabLayout: null });
app._applyTabLayout(layout);
expect(app._tabCollapseStorageFailed).toBe(false);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
expect(app.toggleTabGroupCollapsed('group-x', true)).toBe(true);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('false');
});
it('lights a collapsed header with the most urgent alert it hides, on both render paths', () => {
const app = makeApp();
app.tabAlerts.set('s1', 'idle');
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
const header = () => document.querySelector('[data-tab-group-header="group-x"]')!;
const ring = () => ['tab-alert-action', 'tab-alert-idle'].filter((c) => header().classList.contains(c));
// s1 is hidden (s2 is the kept selection): its yellow alert moves to the header.
expect(rowIds()).toEqual(['s2', 's3']);
expect(ring()).toEqual(['tab-alert-idle']);
// A red alert arriving later is patched in place, without a rebuild.
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.tabAlerts.set('s1', 'action');
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(ring()).toEqual(['tab-alert-action']);
// The kept selection draws its own ring, so its alert never reaches the header.
app.tabAlerts.clear();
app.tabAlerts.set('s2', 'action');
app._renderSessionTabsImmediate();
expect(ring()).toEqual([]);
expect(document.querySelector('[data-id="s2"]')!.classList.contains('tab-alert-action')).toBe(true);
// Expanded, every row shows its own alert and the header shows none.
app.tabAlerts.set('s1', 'action');
app.toggleTabGroupCollapsed('group-x', false);
expect(ring()).toEqual([]);
});
it("lets Ctrl+Shift moves swap only within the active session's own section", () => {
const app = makeApp();
app.saveSessionOrder = vi.fn();
app._fullRenderSessionTabs();
// s2's neighbours in sessionOrder (s1,s2,s3): s3 is Ungrouped, s1 shares group-x.
app.moveActiveTabRight();
expect(app.sessionOrder).toEqual(['s1', 's2', 's3']);
expect(app.saveSessionOrder).not.toHaveBeenCalled();
app.moveActiveTabLeft();
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
expect(app.saveSessionOrder).toHaveBeenCalledTimes(1);
// A row a collapse hides is still in its section.
app.sessionOrder = ['s1', 's2', 's3'];
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
expect(rowIds()).not.toContain('s1');
app.moveActiveTabLeft();
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
// The flat rail (and the strip) keep moving across every neighbour.
const flat = makeApp({ tabLayout: null });
flat.saveSessionOrder = vi.fn();
flat._fullRenderSessionTabs();
flat.moveActiveTabRight();
expect(flat.sessionOrder).toEqual(['s1', 's3', 's2']);
});
});
describe('layout adoption', () => {
@@ -294,6 +367,67 @@ describe('layout adoption', () => {
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
});
it('rebuilds the strip on adoption only when what the rail draws changed', () => {
const flat = makeApp({ tabLayout: null });
flat._fullRenderSessionTabs();
const flatRender = vi.spyOn(flat, '_fullRenderSessionTabs');
// The flat rail (every owner without groups) never rebuilds for a layout read.
flat._applyTabLayout({ version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] });
flat._applyTabLayout({ version: 4, groups: [], ungrouped: [] });
flat._applyTabLayout(null);
expect(flatRender).not.toHaveBeenCalled();
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
const render = vi.spyOn(app, '_fullRenderSessionTabs');
app._applyTabLayout(layout);
expect(render).toHaveBeenCalledTimes(1);
// A version bump that moves nothing (the broadcast after any session event).
app._applyTabLayout({ ...layout, version: 9 });
expect(render).toHaveBeenCalledTimes(1);
// A rename does change the rail.
app._applyTabLayout({ ...layout, version: 10, groups: [{ ...layout.groups[0], name: 'Ops' }] });
expect(render).toHaveBeenCalledTimes(2);
expect(document.querySelector('.tab-layout-group-name')?.textContent).toBe('Ops');
// Grouped to flat (a failed read) rebuilds once.
app._applyTabLayout(null);
expect(render).toHaveBeenCalledTimes(3);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
});
it('backs off failed layout reads and stops, without rebuilding a flat rail', async () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
app._tabLayoutCoordinator = null;
app._apiJson = vi.fn(async () => {
throw new Error('offline');
});
const render = vi.spyOn(app, '_fullRenderSessionTabs');
const realSetTimeout = window.setTimeout;
const delays: number[] = [];
let queued: (() => Promise<boolean>) | null = null;
window.setTimeout = (fn: () => Promise<boolean>, ms: number) => {
delays.push(ms);
queued = fn;
return delays.length;
};
try {
await app._loadTabLayout();
// Bounded: an uncapped retry must fail the assertion below, not hang.
for (let drained = 0; queued && drained < 10; drained++) {
const next: () => Promise<boolean> = queued;
queued = null;
await next();
}
} finally {
window.setTimeout = realSetTimeout;
}
expect(delays).toEqual([5000, 10000, 20000, 40000]);
expect(app._apiJson).toHaveBeenCalledTimes(5);
expect(render).not.toHaveBeenCalled();
app._tabLayoutCoordinator.dispose();
});
it('ignores an older layout than the one it already shows', () => {
const app = makeApp();
app.tabLayout = { ...layout, version: 9 };
@@ -503,9 +637,90 @@ describe('grouped rail tree semantics', () => {
press('Home');
press('Enter');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
// The header was rebuilt by the toggle; focus followed it, never to <body>.
expect(focused()).toBe('group:group-x');
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
});
it('leaves keys pressed on a control inside a row to that control', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
// The overflow button gets focus from a click, or back from its action menu.
const more = row('s2').querySelector<HTMLElement>('.tab-more')!;
more.focus();
expect(press('Enter')).toBe(true);
expect(press(' ')).toBe(true);
expect(press('ArrowDown')).toBe(true);
expect(press('ArrowLeft')).toBe(true);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.collapsedTabGroupIds.size).toBe(0);
expect(document.activeElement).toBe(more);
});
it('treats a group with no open rows as a leaf that Left/Right cannot open or close', () => {
const app = makeTreeApp({
tabLayout: { ...layout, groups: [...layout.groups, { id: 'empty', name: 'Empty', refs: [] }] },
});
app._fullRenderSessionTabs();
const header = document.querySelector<HTMLElement>('[data-tab-group-header="empty"]')!;
expect(header.hasAttribute('aria-expanded')).toBe(false);
expect(header.hasAttribute('aria-owns')).toBe(false);
header.focus();
press('ArrowRight');
press('ArrowLeft');
expect(app.collapsedTabGroupIds.size).toBe(0);
expect(focused()).toBe('group:empty');
});
it('keeps tree positions in painted order when the sorted rail re-sorts rows in place', () => {
const app = makeTreeApp();
document.documentElement.dataset.tabRailSort = 'activity';
let order = new Map([
['s2', 0],
['s1', 1],
['s3', 2],
]);
app._tabRailSortOrder = () => order;
app._fullRenderSessionTabs();
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
app._applyTabTreePositions(tabs());
const pos = (id: string) => row(id).getAttribute('aria-posinset');
expect([pos('s2'), pos('s1'), pos('w1')]).toEqual(['1', '2', '3']);
// s1 goes working: the incremental pass moves its card up with `order` only.
const full = vi.spyOn(app, '_fullRenderSessionTabs');
order = new Map([
['s1', 0],
['s2', 1],
['s3', 2],
]);
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(row('s1').style.order).toBe('0');
expect([pos('s1'), pos('s2'), pos('w1')]).toEqual(['1', '2', '3']);
expect(row('s1').getAttribute('aria-setsize')).toBe('3');
});
it('exempts tree items from the touch keyboard dismissal, like every other control', () => {
// terminal-ui.js publishes the selector on window.CodemanTerminalInput; its
// top level only needs a window and a CodemanApp prototype to assign to.
const context = vm.createContext({ window: {}, CodemanApp: function CodemanApp() {} });
vm.runInContext(readFileSync(join(PUBLIC, 'terminal-ui.js'), 'utf8'), context);
const selector = (context.window as any).CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR as string;
const app = makeTreeApp();
app._fullRenderSessionTabs();
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
// The roving stop sits on s2: the header and the other rows are tabindex=-1.
expect(header.tabIndex).toBe(-1);
expect(row('s1').tabIndex).toBe(-1);
for (const target of [header, header.querySelector('.tab-layout-group-name')!, row('s1'), row('w1')]) {
expect(target.closest(selector), (target as HTMLElement).className).not.toBeNull();
}
// Inert rail chrome still dismisses.
expect(document.querySelector('.tab-layout-ungrouped-header')!.closest(selector)).toBeNull();
});
it('opens row actions from the keyboard, since its controls left the tab order', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();