Files
Codeman/src/web/public/tab-layout-browser.js
T
Aamer Akhter 98c6c1881d feat(tabs): full-row activation and tree semantics for the grouped rail
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.

Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
  role=tablist and label when grouping ends. The header strip, sidebar and
  flat rail keep role=tablist / role=tab exactly as before (the flat rail's
  markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
  aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
  row a collapsed group keeps showing are level-1 items; a collapsed header
  owns nothing, and the "Ungrouped" heading is a visual divider hidden from
  assistive tech. aria-level, aria-setsize and aria-posinset are set on every
  item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
  leave the tab order, so Shift+F10 / ContextMenu open a row's actions
  (session action menu, web tab settings).
- Up/Down walk 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. With the activity sort on, the walk follows
  painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
  focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
  click through the same onclick and still the lineage proxy anchor.

Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
  upstream; that is now pinned in real Chromium for the strip, the flat rail
  and the grouped rail, together with every control (gear, detach, close,
  overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
  default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
  selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
  hovered (no reflow-on-hover moving the gear out from under a click).

New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).
2026-10-01 22:21:45 -04:00

270 lines
12 KiB
JavaScript

/**
* @fileoverview Read-only browser projection of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns three things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a bounded retry.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
const copyRefs = (value) =>
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const raw = storage.getItem(COLLAPSED_STORAGE_KEY);
const parsed = raw === null ? [] : JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('Invalid collapsed tab groups');
const loaded = stableIds(parsed);
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef }} null when the
* layout has no groups: the caller renders the flat rail unchanged.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
count++;
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
continue;
}
const copy = { kind: ref.kind, id: ref.id };
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, count } = place(group.refs, group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({ id: null, name: '', refs: ungrouped.shown, count: ungrouped.count, collapsed: false });
}
return { sections, visibleRefs, hiddenTabGroupByRef };
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where), as opposed to a row's own status/name/badges. The incremental render
* path only patches rows in place, so a change here forces a full rebuild.
*/
function structureKey(layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
version: layout && Number.isSafeInteger(layout.version) ? layout.version : null,
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [section.id, section.count, section.refs.map(refKey)]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The caller makes the list itself the `tree` and marks rows up as treeitems
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
* (the rows sit below the header visually, not inside it).
* - a COLLAPSED group owns nothing: the one row it still shows (the
* selection) is a level-1 sibling, never the child of a closed node.
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
* divider only, hidden from assistive tech, and its rows are not a group.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
const nameId = `tab-layout-group-name-${index}`;
const expanded = !section.collapsed;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}" aria-expanded="${expanded ? 'true' : 'false'}"${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*/
function createLoadCoordinator(options) {
let generation = 0;
let disposed = false;
let retryHandle = null;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
options.applyFallback();
retryHandle = options.scheduleRetry(() => load());
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
structureKey,
renderProjection,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
};
})(typeof window !== 'undefined' ? window : globalThis);