diff --git a/CLAUDE.md b/CLAUDE.md index a77a3871..236b2740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,7 +254,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) +**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) diff --git a/config/test-suites.ts b/config/test-suites.ts index 9f3ed295..35693e24 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -20,6 +20,7 @@ */ export const BROWSER_TEST_GLOBS = [ 'test/tab-rail-resize.browser.test.ts', + 'test/tab-activation.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index bfb4257d..9765c6a8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -303,6 +303,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption, any storage failure means all-expanded). A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. - **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. +- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. +- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. diff --git a/src/web/public/app.js b/src/web/public/app.js index 62aefb20..6b295797 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5104,6 +5104,7 @@ class CodemanApp { tab.classList.remove('active'); } } + this._syncTabTreeSelection(container); // #257: selection used to stop at the class toggle. On phones/tablets the // strip scrolls horizontally, so a tab selected from the palette, a swipe, // Alt+N or a push notification could stay parked off-screen. @@ -5600,6 +5601,12 @@ class CodemanApp { const prevScrollTop = container.scrollTop; const prevActiveTabId = this._lastRenderedActiveTabId; const isFirstRender = !container.querySelector('.session-tab'); + // The rebuild below destroys the focused row. In the grouped tree, put focus + // back on the same item (by identity) so a background render or a keyboard + // collapse does not drop a keyboard user to . + const focusWasInside = container.contains(document.activeElement); + const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null); + this._tabFocusIdentity = null; // Build tabs HTML using array for better string concatenation performance. // Iterate in sessionOrder to respect the user's custom tab arrangement, on @@ -5740,6 +5747,8 @@ class CodemanApp { container.innerHTML = parts.join(''); container.classList.toggle('session-tabs--grouped', !!groupProjection); + this._applyTabListRole(container, !!groupProjection); + if (groupProjection) this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside }); // Put the strip back where the user left it, then reveal the active tab // only when it CHANGED (or on the first paint). Restoring unconditionally @@ -5790,6 +5799,12 @@ class CodemanApp { } this._tabKeydownHandler = (e) => { + // The grouped rail is a tree with its own key model; everything else + // (header strip, sidebar, flat rail) keeps the tab-strip walk below. + if (container.getAttribute('role') === 'tree') { + this._handleTabTreeKeydown(e, container); + return; + } // Up/Down are aliases of Left/Right, not replacements: the strip stays // arrow-key navigable exactly as before, the vertical sidebar just gains // the axis a user reaches for there. @@ -5807,19 +5822,14 @@ class CodemanApp { // sort is stable, so equal orders keep DOM order, which is the unsorted case. if (this.isTabRailSorted()) { const orderOf = (el) => Number(getComputedStyle(el).order) || 0; - // Grouped rail: `order` only sorts WITHIN a group's own flex column, so - // the walk sorts by group first (-1 for every row of the flat strip). - const groups = [...container.querySelectorAll('.tab-layout-group')]; - const groupOf = (el) => groups.indexOf(el.closest('.tab-layout-group')); - tabs.sort((a, b) => groupOf(a) - groupOf(b) || orderOf(a) - orderOf(b)); + tabs.sort((a, b) => orderOf(a) - orderOf(b)); } const currentIndex = tabs.indexOf(document.activeElement); // Enter or Space activates the tab if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) { e.preventDefault(); - const sessionId = tabs[currentIndex].dataset.id; - this.selectSession(sessionId, { forceReload: true }); + this._activateTabRow(tabs[currentIndex]); return; } @@ -5850,6 +5860,216 @@ class CodemanApp { }; container.addEventListener('keydown', this._tabKeydownHandler); + + // Grouped tree: a row or header focused by pointer becomes the tab stop, so + // Tab-ing away and back returns to it and there is still exactly one stop. + if (!this._tabTreeFocusinHandler) { + this._tabTreeFocusinHandler = (e) => { + if (container.getAttribute('role') !== 'tree') return; + const item = e.target?.closest?.('[role="treeitem"]'); + if (item && container.contains(item)) this._setTabTreeStop(container, item); + }; + container.addEventListener('focusin', this._tabTreeFocusinHandler); + } + } + + /** Select a session row or open a web-tab row (Enter/Space, either layout). */ + _activateTabRow(row) { + if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId); + if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true }); + return undefined; + } + + // ═══════════════════════════════════════════════════════════════ + // Grouped rail: tree semantics and keyboard model + // ═══════════════════════════════════════════════════════════════ + // + // Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree; + // named-group headers are level-1 treeitems that own their rows (level 2); + // ungrouped rows and a collapsed group's kept selection are level 1. One item + // carries tabindex=0 (roving), every control inside a row is removed from the + // tab order, and focus is restored by identity across full re-renders. The + // flat rail and the header strip keep role=tablist / role=tab untouched. + + /** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */ + _applyTabListRole(container, grouped) { + if (grouped) { + container.setAttribute('role', 'tree'); + container.setAttribute('aria-label', 'Sessions'); + } else if (container.getAttribute('role') === 'tree') { + container.setAttribute('role', 'tablist'); + container.setAttribute('aria-label', 'Session tabs'); + } + } + + /** Stable identity of a tree item (or anything inside one) across re-renders. */ + _tabTreeIdentity(element) { + const item = element?.closest?.('[data-tab-group-header], .session-tab'); + if (!item) return null; + if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`; + if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`; + if (item.dataset.id) return `session:${item.dataset.id}`; + return null; + } + + /** + * Visible tree items in the order the eye reads them: each section's header, + * then its rows. A sorted rail paints rows with the flex `order` property + * inside their own group column, so rows are ordered by COMPUTED order within + * a section (stable, so the unsorted rail keeps DOM order). + */ + _tabTreeItems(container) { + const sorted = this.isTabRailSorted(); + const orderOf = (el) => Number(getComputedStyle(el).order) || 0; + const items = []; + for (const section of container.querySelectorAll('.tab-layout-group')) { + const header = section.querySelector(':scope > [role="treeitem"]'); + if (header) items.push(header); + const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')]; + if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b)); + items.push(...rows); + } + return items; + } + + /** Move the single tab stop to `item` (every other tree item gets -1). */ + _setTabTreeStop(container, item) { + for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1; + } + + /** + * Turn freshly rendered rows into tree items and place the roving tab stop. + * Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and + * its controls); only their semantics change here, never their content. + */ + _applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) { + for (const row of container.querySelectorAll('.session-tab')) { + row.setAttribute('role', 'treeitem'); + row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false'); + row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1'); + // Controls stay clickable, but leave the tab order: the tree has ONE stop, + // and a row's actions are reachable from it with Shift+F10 / ContextMenu. + for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) { + control.tabIndex = -1; + } + } + for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1'); + // Position within each level: the level-1 run (headers, ungrouped rows, a + // collapsed group's kept row) and each group's own rows. + const items = this._tabTreeItems(container); + const sets = new Map(); + for (const item of items) { + const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container; + if (!sets.has(owner)) sets.set(owner, []); + sets.get(owner).push(item); + } + for (const members of sets.values()) { + members.forEach((item, index) => { + item.setAttribute('aria-setsize', String(members.length)); + item.setAttribute('aria-posinset', String(index + 1)); + }); + } + + const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null); + // A focused row that a collapse just hid hands focus to its group header. + const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null; + const target = + byIdentity(identity) || + (hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) || + items.find((item) => item.getAttribute('aria-selected') === 'true') || + items[0]; + if (!target) return; + this._setTabTreeStop(container, target); + if (refocus && document.activeElement !== target) target.focus(); + } + + /** Keep aria-selected on the grouped tree in step with the .active class. */ + _syncTabTreeSelection(container) { + if (container?.getAttribute('role') !== 'tree') return; + for (const row of container.querySelectorAll('.session-tab')) { + row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false'); + } + } + + /** + * Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the + * visible items, Home/End jump, Right expands a header or enters it, Left + * collapses a header or climbs from a row to its header, Enter/Space select a + * row or toggle a header, Shift+F10 / ContextMenu open a row's actions. + */ + _handleTabTreeKeydown(e, container) { + if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return; + const items = this._tabTreeItems(container); + const current = e.target?.closest?.('[role="treeitem"]'); + const index = items.indexOf(current); + if (index < 0) return; + const groupId = current.dataset.tabGroupHeader || null; + const expanded = current.getAttribute('aria-expanded') === 'true'; + const focusAt = (next) => { + if (!next) return; + this._setTabTreeStop(container, next); + next.focus(); + }; + const toggle = (collapse) => { + // The toggle re-renders the rail; keep focus on this header through it. + this._tabFocusIdentity = `group:${groupId}`; + this.toggleTabGroupCollapsed(groupId, collapse); + }; + + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': { + const step = e.key === 'ArrowDown' ? 1 : -1; + focusAt(items[(index + step + items.length) % items.length]); + break; + } + case 'Home': + focusAt(items[0]); + break; + case 'End': + focusAt(items[items.length - 1]); + break; + case 'ArrowRight': + if (!groupId) return; + if (!expanded) toggle(false); + else { + const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns')); + if (!child) return; + focusAt(child); + } + break; + case 'ArrowLeft': + if (groupId) { + if (!expanded) return; + toggle(true); + } else { + const group = current.closest('[role="group"]'); + const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null; + if (!header) return; + focusAt(header); + } + break; + case 'Enter': + case ' ': + if (groupId) toggle(); + else this._activateTabRow(current); + break; + case 'F10': + case 'ContextMenu': + if (e.key === 'F10' && !e.shiftKey) return; + if (current.dataset.id) { + this.openTabRailActionMenu?.( + { preventDefault() {}, stopPropagation() {}, currentTarget: current }, + current.dataset.id + ); + } else if (current.dataset.webviewId) { + this.showWebviewModal?.(current.dataset.webviewId); + } else return; + break; + default: + return; + } + e.preventDefault(); } handleSessionTabClick(event, sessionId) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index d061b11c..f5aad65c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -671,6 +671,7 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header { html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle { cursor: pointer; + user-select: none; } html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover { @@ -1779,7 +1780,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix { line-height: 1; color: var(--text-muted); overflow: hidden; - cursor: default; + /* A control, like its gear/detach siblings: the pointer must not read as + "nothing here" over the one button that closes the session. */ + cursor: pointer; border-radius: 3px; /* Performance: use transform for GPU acceleration */ transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out; diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index 4661b810..fb32e0b0 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -179,8 +179,15 @@ * `escapeHtml` is the caller's escaper. Group names are user content, so they * are escaped and marked `data-i18n-skip`. * - * The group header is a real ` + - `` + `${escapeHtml(section.name)}` + + `${section.count}` + + `
${rows}
` ); }) .join(''); diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index fc0fd28f..7657c4dd 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -355,6 +355,7 @@ Object.assign(CodemanApp.prototype, { // A web tab is active, so no session tab may also look active. for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active'); } + this._syncTabTreeSelection?.(container); }, // ── Opening / closing ───────────────────────────────────────────────────── diff --git a/test/tab-activation.browser.test.ts b/test/tab-activation.browser.test.ts new file mode 100644 index 00000000..e29c6ed4 --- /dev/null +++ b/test/tab-activation.browser.test.ts @@ -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: '' }) + ); + await page.goto('http://codeman.test/'); + await page.setContent(` + + + +
+
+ +
+ + `); + 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', + '
' + ); + 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); + }); +}); diff --git a/test/tab-layout-browser.test.ts b/test/tab-layout-browser.test.ts index f68dc983..f74959fa 100644 --- a/test/tab-layout-browser.test.ts +++ b/test/tab-layout-browser.test.ts @@ -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('[data-tab-group-header="g1"]')!; - const g2 = doc.querySelector('[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(''); + 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('[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('.row')].map((el) => el.dataset.ref)).toEqual( ids(projection.visibleRefs) ); diff --git a/test/tab-layout-rail.test.ts b/test/tab-layout-rail.test.ts index 0f0caaad..f125319b 100644 --- a/test/tab-layout-rail.test.ts +++ b/test/tab-layout-rail.test.ts @@ -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 = {}) => + (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(`[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('[data-tab-group-header="group-x"]')!; + const group = document.getElementById(header.getAttribute('aria-owns')!)!; + expect(group.getAttribute('role')).toBe('group'); + expect( + [...group.querySelectorAll('[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('[tabindex="0"]')]; + expect(stops).toEqual([row('s2')]); + const controls = [...tabs().querySelectorAll('.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('[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('[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('[data-id="s2"]')!; + s2.focus(); + s2.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + expect(app.selectSession).toHaveBeenCalledWith('s2', { forceReload: true }); + }); +});