Merge pull request #519 from aakhter/pr/grouped-rail-tree

feat(tabs): full-row activation and tree semantics for the grouped rail
This commit is contained in:
Codeman maintainer
2026-10-04 23:24:39 +02:00
10 changed files with 809 additions and 24 deletions
+303
View File
@@ -0,0 +1,303 @@
/**
* @fileoverview Real-Chromium coverage for tab-row activation and the grouped rail tree.
*
* A tab row is one activation target: its status dot, mode chip, name and unused
* padding all select it, while its own controls (gear, detach, close, the rail's
* overflow button, a web tab's gear and close) run only their own action. The
* controls must also keep a stable hit target: revealing one on hover may not
* slide its neighbours out from under a pointer already aiming at them.
*
* 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.
*
* Port: none (page.setContent, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
describe('tab row activation in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
// A real origin, so localStorage (per-device collapse) works as it does in the app.
await page.route('http://codeman.test/', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
);
await page.goto('http://codeman.test/');
await page.setContent(`<!doctype html>
<html data-tab-orientation="horizontal" data-tab-rail-sort="manual">
<head><style>${read('styles.css')}</style></head>
<body>
<header class="header"><div id="sessionTabsHost"></div></header>
<main class="main" style="width:100%;height:700px">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation"></aside>
</main>
</body>
</html>`);
await page.addScriptTag({
content:
'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') +
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
});
await page.addScriptTag({ content: read('webview-tabs.js') });
await page.evaluate(() => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['one', { id: 'one', name: 'One', status: 'idle', mode: 'shell' }],
['two', { id: 'two', name: 'Two', status: 'idle' }],
]);
app.sessionOrder = ['one', 'two'];
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
app.webviewOrder = ['web'];
app.activeSessionId = 'one';
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 = () => true;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => undefined;
app.updateTabOverflowMode = () => undefined;
app.updateConnectionLines = () => undefined;
app._applyTabEntrances = () => undefined;
app._scrollActiveTabIntoView = () => undefined;
app.applySidebarFilter = () => undefined;
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => undefined;
app._stopSidebarRichClock = () => undefined;
const record = (key: string, value: string) => () => {
w[key] = value;
};
app.selectSession = (id: string) => {
w.__activation = `session:${id}`;
};
app.openWebview = (id: string) => {
w.__activation = `webview:${id}`;
};
app.openSessionOptions = record('__action', 'settings');
app.detachSession = record('__action', 'detach');
app.requestCloseSession = record('__action', 'close');
app.openTabRailActionMenu = record('__action', 'overflow');
app.showWebviewModal = record('__action', 'web-settings');
app.closeWebviewTab = record('__action', 'web-close');
w.__setApp(app);
w.__app = app;
});
});
afterAll(async () => browser.close());
/** Render the strip/rail in a given shape, from scratch. */
async function render(orientation: 'horizontal' | 'vertical', grouped = false) {
await page.evaluate(
({ orientation, grouped }) => {
const w = window as any;
document.documentElement.setAttribute('data-tab-orientation', orientation);
const host =
orientation === 'vertical'
? document.getElementById('tabRail')!
: document.getElementById('sessionTabsHost')!;
document.getElementById('sessionTabs')?.remove();
host.insertAdjacentHTML(
'afterbegin',
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
);
w.__app._tabKeydownHandler = null;
w.__app.activeSessionId = 'one';
w.__app.activeWebviewId = null;
w.__app.tabLayout = grouped
? {
version: 1,
groups: [
{
id: 'g',
name: 'Group',
refs: [
{ kind: 'session', id: 'one' },
{ kind: 'webview', id: 'web' },
],
},
],
ungrouped: [{ kind: 'session', id: 'two' }],
}
: null;
w.__app._fullRenderSessionTabs();
w.__activation = null;
w.__action = null;
},
{ orientation, grouped }
);
await page.mouse.move(1, 1);
}
const result = () =>
page.evaluate(() => ({ activation: (window as any).__activation, action: (window as any).__action }));
const reset = () => page.evaluate(() => ((window as any).__activation = (window as any).__action = null));
for (const [label, orientation, grouped] of [
['horizontal strip', 'horizontal', false],
['flat vertical rail', 'vertical', false],
['grouped vertical rail', 'vertical', true],
] as const) {
it(`activates a row from its status, mode, name and padding (${label})`, async () => {
await render(orientation, grouped);
for (const [selector, expected] of [
['[data-id="two"] .tab-status', 'session:two'],
['[data-id="two"] .tab-name', 'session:two'],
['[data-id="one"] .tab-mode', 'session:one'],
['[data-webview-id="web"] .tab-name', 'webview:web'],
] as const) {
await reset();
await page.locator(selector).click();
expect((await result()).activation, selector).toBe(expected);
}
for (const [selector, expected] of [
['[data-id="two"]', 'session:two'],
['[data-webview-id="web"]', 'webview:web'],
] as const) {
await reset();
const box = (await page.locator(selector).boundingBox())!;
await page.mouse.click(box.x + 2, box.y + box.height / 2);
expect((await result()).activation, `${selector} leading padding`).toBe(expected);
// The cursor advertises the whole row: a pointer, or the grab hand where
// the row is also a drag-reorder handle (the flat lists).
const { cursor, draggable } = await page
.locator(selector)
.evaluate((el) => ({ cursor: getComputedStyle(el).cursor, draggable: el.getAttribute('draggable') }));
expect(cursor, selector).toBe(draggable === 'true' ? 'grab' : 'pointer');
}
});
it(`runs a control's own action without selecting the row (${label})`, async () => {
await render(orientation, grouped);
for (const [selector, expected] of [
['[data-id="one"] .tab-gear', 'settings'],
['[data-id="one"] .tab-detach', 'detach'],
['[data-id="one"] .tab-close', 'close'],
] as const) {
await reset();
const control = page.locator(selector);
expect(await control.isVisible(), selector).toBe(true);
expect(await control.evaluate((el) => getComputedStyle(el).cursor), selector).toBe('pointer');
await control.click();
expect(await result(), selector).toEqual({ activation: null, action: expected });
}
if (orientation === 'vertical') {
await reset();
await page.locator('[data-id="one"] .tab-more').click();
expect(await result()).toEqual({ activation: null, action: 'overflow' });
}
await page.evaluate(() => {
(window as any).__app.activeWebviewId = 'web';
(window as any).__app._fullRenderSessionTabs();
});
for (const [selector, expected] of [
['[data-webview-id="web"] .tab-gear', 'web-settings'],
['[data-webview-id="web"] .tab-close', 'web-close'],
] as const) {
await reset();
await page.locator(selector).click();
expect(await result(), selector).toEqual({ activation: null, action: expected });
}
});
it(`keeps action controls anchored under the pointer when a row is hovered (${label})`, async () => {
// Revealing a control on hover must not reflow its row: a button that
// toggled `display` on hover once slid the gear 28px left, so the click
// aimed at the gear landed on the close button instead. Assert the
// geometry, not a CSS property, so any reflow-on-hover rewrite fails here.
await render(orientation, grouped);
for (const selector of ['[data-id="one"] .tab-gear', '[data-id="one"] .tab-close']) {
await page.mouse.move(1, 1);
const control = page.locator(selector);
const before = (await control.boundingBox())!;
const aim = { x: Math.round(before.x + before.width / 2), y: Math.round(before.y + before.height / 2) };
await page.mouse.move(aim.x, aim.y);
await page.waitForTimeout(250);
const after = (await control.boundingBox())!;
expect(Math.abs(after.x - before.x), selector).toBeLessThanOrEqual(4);
const stillOnTarget = await page.evaluate(
({ point, selector }) => !!document.elementFromPoint(point.x, point.y)?.closest(selector),
{ point: aim, selector }
);
expect(stillOnTarget, selector).toBe(true);
}
});
}
it('toggles a group from its header without selecting anything', async () => {
await render('vertical', true);
const header = page.locator('[data-tab-group-header="g"]');
await header.click();
expect(await result()).toEqual({ activation: null, action: null });
expect(await header.getAttribute('aria-expanded')).toBe('false');
await page.locator('[data-tab-group-header="g"]').click();
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
});
it('walks the grouped rail tree from the keyboard with one tab stop', async () => {
await render('vertical', true);
const tree = page.locator('#sessionTabs[role="tree"]');
expect(await tree.count()).toBe(1);
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
// The roving tab stop starts on the selected row.
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
await page.keyboard.press('ArrowLeft');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
await page.keyboard.press('ArrowLeft');
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('false');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
await page.keyboard.press('ArrowRight');
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
await page.keyboard.press('ArrowRight');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
await page.keyboard.press('End');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('two');
await page.keyboard.press('Home');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-webview-id'))).toBe('web');
await reset();
await page.keyboard.press('Enter');
expect((await result()).activation).toBe('webview:web');
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
});
it('keeps tab semantics and no tree roles without groups', async () => {
await render('vertical', false);
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
expect(
await page
.locator('#sessionTabs [role="tree"], #sessionTabs [role="treeitem"], #sessionTabs [role="group"]')
.count()
).toBe(0);
expect(await page.locator('#sessionTabs .session-tab[role="tab"]').count()).toBe(3);
});
});
+26 -6
View File
@@ -169,7 +169,7 @@ describe('per-device collapse storage', () => {
});
describe('grouped markup', () => {
it('renders escaped group sections with real toggle buttons around caller-rendered rows', () => {
it('renders escaped group sections as a tree skeleton around caller-rendered rows', () => {
const h = loadHelper();
const projection = h.project(layout(), {
liveSessionIds: ['s1', 's2', 's3', 's4'],
@@ -189,15 +189,35 @@ describe('grouped markup', () => {
]);
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
// Sections are layout only; the caller's container is the tree.
expect([...doc.querySelectorAll('section')].every((el) => el.getAttribute('role') === 'presentation')).toBe(true);
// A named header is a level-1 treeitem that toggles and OWNS its rows' group.
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
expect(g1.tagName).toBe('BUTTON');
expect(g1.getAttribute('role')).toBe('treeitem');
expect(g1.getAttribute('tabindex')).toBe('-1');
expect(g1.getAttribute('aria-expanded')).toBe('true');
expect(g1.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
const owned = doc.getElementById(g1.getAttribute('aria-owns')!)!;
expect(owned.getAttribute('role')).toBe('group');
expect(doc.getElementById(owned.getAttribute('aria-labelledby')!)?.textContent).toBe('<Core & Ops>');
expect(owned.querySelectorAll('.row')).toHaveLength(3);
// No interactive element nested inside a treeitem.
expect(g1.querySelectorAll('button, [tabindex]')).toHaveLength(0);
// A collapsed header owns nothing, so its kept row cannot read as the child of a closed node.
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
expect(g2.getAttribute('aria-expanded')).toBe('false');
expect(doc.getElementById(g1.getAttribute('aria-controls')!)?.querySelectorAll('.row')).toHaveLength(3);
// The ungrouped header is a label, not a control: there is nothing to collapse.
expect(doc.querySelector('.tab-layout-ungrouped-header')?.tagName).toBe('DIV');
expect(g2.hasAttribute('aria-owns')).toBe(false);
expect(g2.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe('presentation');
// The ungrouped heading is a visual divider: nothing to collapse, nothing to announce.
const ungrouped = doc.querySelector('.tab-layout-ungrouped-header')!;
expect(ungrouped.getAttribute('aria-hidden')).toBe('true');
expect(ungrouped.hasAttribute('role')).toBe(false);
expect(ungrouped.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
'presentation'
);
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
ids(projection.visibleRefs)
);
+228 -2
View File
@@ -5,8 +5,9 @@
* 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, and lineage arcs to a collapse-hidden session anchor
* to its group header.
* 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.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
* `document`, `localStorage` and `window` below are that window's, not Node's.
@@ -375,3 +376,228 @@ describe('lineage in the grouped rail', () => {
expect(app._lineageEdgeCount).toBe(0);
});
});
describe('grouped rail tree semantics', () => {
const tabs = () => document.getElementById('sessionTabs')!;
const press = (key: string, init: Record<string, unknown> = {}) =>
(document.activeElement as HTMLElement).dispatchEvent(
new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
);
const focused = () => {
const el = document.activeElement as HTMLElement;
return el.dataset.tabGroupHeader ? `group:${el.dataset.tabGroupHeader}` : el.dataset.webviewId || el.dataset.id;
};
const row = (id: string) => document.querySelector<HTMLElement>(`[data-id="${id}"], [data-webview-id="${id}"]`)!;
function makeTreeApp(options: { tabLayout?: unknown } = {}) {
const app = makeApp(options);
// The container as index.html ships it.
tabs().setAttribute('role', 'tablist');
tabs().setAttribute('aria-label', 'Session tabs');
app.selectSession = vi.fn();
app.openWebview = vi.fn();
app.openTabRailActionMenu = vi.fn();
app.showWebviewModal = vi.fn();
return app;
}
it('is a tree only while grouped, and the flat list returns byte-identical as a tablist', () => {
const app = makeTreeApp({ tabLayout: null });
app._fullRenderSessionTabs();
const flat = tabs().innerHTML;
expect(tabs().querySelectorAll('[role="tree"], [role="treeitem"], [role="group"]')).toHaveLength(0);
expect(tabs().querySelectorAll('.session-tab[role="tab"]')).toHaveLength(4);
app._applyTabLayout(layout);
expect(tabs().getAttribute('role')).toBe('tree');
expect(tabs().getAttribute('aria-label')).toBe('Sessions');
expect(tabs().querySelectorAll('[role="tab"]')).toHaveLength(0);
expect(tabs().querySelectorAll('.session-tab[role="treeitem"]')).toHaveLength(4);
app._applyTabLayout(null);
expect(tabs().getAttribute('role')).toBe('tablist');
expect(tabs().getAttribute('aria-label')).toBe('Session tabs');
expect(tabs().innerHTML).toBe(flat);
// The horizontal strip never becomes a tree, groups or not.
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app._applyTabLayout(layout);
expect(tabs().getAttribute('role')).toBe('tablist');
expect(tabs().querySelectorAll('[role="treeitem"]')).toHaveLength(0);
});
it('nests group rows under their owning header with levels and positions', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
const group = document.getElementById(header.getAttribute('aria-owns')!)!;
expect(group.getAttribute('role')).toBe('group');
expect(
[...group.querySelectorAll<HTMLElement>('[role="treeitem"]')].map((el) => el.dataset.webviewId || el.dataset.id)
).toEqual(['s2', 'w1', 's1']);
const aria = (el: HTMLElement) => ['aria-level', 'aria-posinset', 'aria-setsize'].map((a) => el.getAttribute(a));
// Level 1: the group header and the ungrouped row.
expect(aria(header)).toEqual(['1', '1', '2']);
expect(aria(row('s3'))).toEqual(['1', '2', '2']);
expect(aria(row('s2'))).toEqual(['2', '1', '3']);
expect(aria(row('w1'))).toEqual(['2', '2', '3']);
expect(aria(row('s1'))).toEqual(['2', '3', '3']);
// aria-selected follows the active row, exactly once.
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
's2',
]);
});
it('has exactly one tab stop, on the selected row, and no tabbable control inside rows', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
const stops = [...tabs().querySelectorAll<HTMLElement>('[tabindex="0"]')];
expect(stops).toEqual([row('s2')]);
const controls = [...tabs().querySelectorAll<HTMLElement>('.session-tab button, .session-tab [tabindex]')];
expect(controls.length).toBeGreaterThan(0);
expect(controls.every((el) => el.tabIndex === -1)).toBe(true);
// Collapse keeps a single stop (the selection stays visible as a level-1 item).
app.toggleTabGroupCollapsed('group-x', true);
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
expect(row('s2').getAttribute('aria-level')).toBe('1');
expect(document.querySelector('[data-tab-group-header="group-x"]')!.hasAttribute('aria-owns')).toBe(false);
});
it('walks Up/Down/Home/End, collapses and enters groups with Left/Right, and activates with Enter/Space', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
row('s2').focus();
press('ArrowUp');
expect(focused()).toBe('group:group-x');
press('ArrowUp');
expect(focused()).toBe('s3');
press('Home');
expect(focused()).toBe('group:group-x');
press('End');
expect(focused()).toBe('s3');
// An ungrouped row has no parent to climb to.
expect(press('ArrowLeft')).toBe(true);
expect(focused()).toBe('s3');
row('w1').focus();
press('Enter');
expect(app.openWebview).toHaveBeenCalledWith('w1');
row('s1').focus();
press(' ');
expect(app.selectSession).toHaveBeenCalledWith('s1', { forceReload: true });
expect(app.selectSession).toHaveBeenCalledTimes(1);
press('ArrowLeft');
expect(focused()).toBe('group:group-x');
press('ArrowLeft');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
// The header was re-rendered; focus and the tab stop moved to the new node.
expect(focused()).toBe('group:group-x');
expect(document.querySelector('[data-tab-group-header="group-x"]')!.getAttribute('tabindex')).toBe('0');
press('ArrowRight');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(false);
expect(focused()).toBe('group:group-x');
press('ArrowRight');
expect(focused()).toBe('s2');
press('Home');
press('Enter');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
});
it('opens row actions from the keyboard, since its controls left the tab order', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
row('s2').focus();
press('F10', { shiftKey: true });
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
row('w1').focus();
press('ContextMenu');
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
press('F10');
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
});
it('restores focus by identity across a background rebuild and follows pointer focus', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
row('w1').focus();
app._fullRenderSessionTabs();
expect(focused()).toBe('w1');
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('w1')]);
// Focus arriving by pointer (or any other route) takes the tab stop with it.
row('s3').focus();
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('s3')]);
// A rebuild never pulls focus into the rail when it was elsewhere.
(document.activeElement as HTMLElement).blur();
app._fullRenderSessionTabs();
expect(document.activeElement).toBe(document.body);
});
it('keeps aria-selected in step when the selection changes without a rebuild', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.activeSessionId = 's1';
app._updateActiveTabImmediate('s1');
expect(full).not.toHaveBeenCalled();
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
's1',
]);
});
it('walks a sorted rail in painted order: per group in the tree, across the list when flat', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
document.documentElement.dataset.tabRailSort = 'activity';
row('s1').style.order = '0';
row('s2').style.order = '1';
row('w1').style.order = '9999';
row('s3').style.order = '2';
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.focus();
const walk = () =>
Array.from({ length: 4 }, () => {
press('ArrowDown');
return focused();
});
expect(walk()).toEqual(['s1', 's2', 'w1', 's3']);
const flat = makeTreeApp({ tabLayout: null });
flat._fullRenderSessionTabs();
document.documentElement.dataset.tabRailSort = 'activity';
row('s1').style.order = '2';
row('s2').style.order = '0';
row('s3').style.order = '1';
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
row('s2').focus();
expect(
Array.from({ length: 3 }, () => {
press('ArrowDown');
return focused();
})
).toEqual(['s3', 's1', 'w1']);
});
});
describe('flat list keyboard activation', () => {
it('opens a web tab with Enter/Space instead of selecting an undefined session', () => {
const app = makeApp({ tabLayout: null });
app.selectSession = vi.fn();
app.openWebview = vi.fn();
app._fullRenderSessionTabs();
const web = document.querySelector<HTMLElement>('[data-webview-id="w1"]')!;
web.focus();
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
expect(app.openWebview).toHaveBeenCalledTimes(2);
expect(app.selectSession).not.toHaveBeenCalled();
const s2 = document.querySelector<HTMLElement>('[data-id="s2"]')!;
s2.focus();
s2.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(app.selectSession).toHaveBeenCalledWith('s2', { forceReload: true });
});
});