Files
Codeman/test/tab-focus-rail.test.ts
T
Aamer Akhter cde90a465b feat(tabs): pin tabs to a Focus section on the vertical rail
A session or web-tab ref in the owner tab layout may now carry
`focus: true`. "Add to Focus" in a row's menu (right-click on a web tab)
sets it; the vertical rail then draws a shortcut to that tab in a pinned,
collapsible "Focus" section at its top, while the row itself stays where
it is. A click on a shortcut selects its tab, a right-click or the menu
key offers "Remove from Focus".

Model: `TabRef.focus?: true`, parsed strictly (only `true` or absent) and
carried through normalization, every pure edit and the legacy-order
projection; `setTabFocus()` is the pure helper. TabLayoutService needed
no change and no route was added: the flag rides the existing
`PUT /api/tab-layout`. Browser: `copyRef`/`normalizeLayout` keep `focus`
the way they keep `placement` (the browser writes whole layouts, so a
dropped field would be erased on every save), `setFocus` is a named
operation through `createEditCoordinator` and replays on a 409, and
`focusRefs()` (also on `project()`) lists what the rail can draw.

The section is its own element in #tabRail above #sessionTabs, so it
works on the flat rail (no named groups) as well as the grouped one, and
shortcuts are never rows: no Alt+N badge, no tree item, no lineage
anchor, no drag target. It is a list with one tab stop and
Up/Down/Home/End between shortcuts. Collapse is per-device localStorage
under its own key. A shortcut whose session closed or whose web tab is
not open drops out; the server already prunes deleted refs. zh-CN
strings added.
2026-10-10 13:43:20 -04:00

427 lines
19 KiB
TypeScript

/**
* @fileoverview The Focus section of the vertical rail, driven through the shipping CodemanApp.
*
* A ref flagged `focus: true` gets a SHORTCUT in a "Focus" section pinned above
* the rail's list; the row itself stays in its group. Pinned here:
* - the section is its own element in #tabRail, above #sessionTabs, on the
* grouped AND the flat rail, and absent off the vertical rail;
* - shortcuts are not rows: no `.session-tab`, no Alt+N badge, no tree item,
* so the badges, the tree's single tab stop, the row count and drag are
* exactly what they were, and no id is duplicated;
* - a click selects the tab; the selected tab's shortcut is aria-current;
* - "Add to Focus" / "Remove from Focus" in the row and web-tab menus send
* one `setFocus` through the edit coordinator (whole-layout PUT);
* - per-device collapse, a list keyboard model with one tab stop, a gone
* session dropping out, and a flag changed elsewhere repainting the section;
* - the new strings translate to zh-CN.
*
* Port: none (JSDOM, stubbed fetch).
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
type Ref = { kind: 'session' | 'webview'; id: string; placement?: 'manual'; focus?: true };
type Layout = {
version: number;
updatedAt: string;
groups: Array<{ id: string; name: string; refs: Ref[] }>;
ungrouped: Ref[];
};
let CodemanApp: { prototype: Record<string, any> };
let win: any;
let document: Document;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
win = dom.window;
document = win.document;
win.setInterval = () => 0;
win.requestAnimationFrame = () => 0;
win.CSS = { escape: (value: string) => value };
win.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\n' +
read('tab-rail-resize.js') +
'\n' +
read('api-client.js') +
'\n' +
read('webview-tabs.js') +
'\n;window.__FocusCodemanApp = CodemanApp;'
);
CodemanApp = win.__FocusCodemanApp;
});
const s = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'session', id, ...extra });
const w = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'webview', id, ...extra });
const grouped = (version = 8): Layout => ({
version,
updatedAt: '2026-10-09T00:00:00.000Z',
groups: [
{ id: 'gx', name: 'Core', refs: [s('s2', { focus: true }), w('w1', { focus: true })] },
{ id: 'gy', name: 'Later', refs: [] },
],
ungrouped: [s('s1'), s('s3', { focus: true })],
});
const flat = (version = 8): Layout => ({
version,
updatedAt: '2026-10-09T00:00:00.000Z',
groups: [],
ungrouped: [s('s1'), s('s2', { focus: true }), s('s3'), w('w1')],
});
function installFetch() {
const puts: any[] = [];
win.fetch = vi.fn(async (_url: string, init: any) => {
const body = init?.body ? JSON.parse(init.body) : null;
if (init?.method === 'PUT') puts.push(body);
const payload = { success: true, data: { layout: { ...body?.layout, version: (body?.baseVersion ?? 0) + 1 } } };
return { ok: true, status: 200, json: async () => payload };
});
return puts;
}
function makeApp(layout: Layout | null = grouped(), orientation = 'vertical') {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
document.documentElement.setAttribute('data-tab-orientation', orientation);
document.documentElement.dataset.tabRailSort = 'manual';
document.body.innerHTML =
'<aside class="tab-rail" id="tabRail"><div id="tabRailResizeHandle"></div>' +
'<div id="sessionTabs" class="session-tabs" role="tablist" aria-label="Session tabs"></div></aside>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['s1', { id: 's1', name: 'One', status: 'idle' }],
['s2', { id: 's2', name: 'Two <b>', status: 'busy' }],
['s3', { id: 's3', name: 'Three', status: 'idle' }],
]);
app.sessionOrder = ['s1', 's2', 's3'];
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
app.webviewOrder = ['w1'];
app.activeSessionId = 's2';
app.activeWebviewId = null;
app.tabLayout = null;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._tabCollapseStorageFailed = false;
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => {};
app.updateTabOverflowMode = () => {};
app.updateConnectionLines = vi.fn();
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app._refreshMobileOverviewIfVisible = () => {};
app._refreshHomeSessionsIfVisible = () => {};
app.applySidebarFilter = () => {};
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => {};
app._stopSidebarRichClock = () => {};
app.loadAppSettingsFromStorage = () => ({});
app.openSessionOptions = vi.fn();
app.requestCloseSession = vi.fn();
app.selectSession = vi.fn();
app.openWebview = vi.fn();
app.showWebviewModal = vi.fn();
app.showToast = vi.fn();
app._fullRenderSessionTabs();
if (layout) app._applyTabLayout(layout);
return app;
}
const section = () => document.getElementById('tabFocus');
const shortcuts = () => [...document.querySelectorAll<HTMLButtonElement>('#tabFocus .tab-focus-item')];
const shortcutKeys = () => shortcuts().map((item) => item.dataset.focusKey);
const rows = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
(tab) => tab.dataset.webviewId || tab.dataset.id
);
const badges = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
);
const menuLabels = () => [...document.querySelectorAll('.tab-rail-action-menu button')].map((b) => b.textContent);
const clickMenu = (label: string) =>
[...document.querySelectorAll<HTMLElement>('.tab-rail-action-menu button')]
.find((b) => b.textContent === label)!
.click();
const row = (id: string) => document.querySelector<HTMLElement>(`#sessionTabs .session-tab[data-id="${id}"]`)!;
const key = (target: Element, k: string) =>
target.dispatchEvent(new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
const flush = async () => {
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0));
};
const focusedIn = (layout: Layout) =>
[...layout.groups.flatMap((g) => g.refs), ...layout.ungrouped]
.filter((ref) => ref.focus === true)
.map((ref) => `${ref.kind}:${ref.id}`);
beforeEach(() => {
win.localStorage.clear();
win.sessionStorage.clear();
document.body.innerHTML = '';
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
installFetch();
});
afterEach(() => {
document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove());
});
describe('the Focus section', () => {
it('draws shortcuts in stored order in its own section above the grouped list', () => {
makeApp();
const host = section()!;
expect(host.hidden).toBe(false);
expect(host.parentElement!.id).toBe('tabRail');
expect(host.nextElementSibling!.id).toBe('sessionTabs');
expect(shortcutKeys()).toEqual(['session:s2', 'webview:w1', 'session:s3']);
expect(shortcuts().map((item) => item.textContent)).toEqual(['Two <b>', 'Dashboard', 'Three']);
expect(document.getElementById('tabFocusToggle')!.textContent).toBe('Focus3');
expect(document.getElementById('tabFocusList')!.getAttribute('aria-labelledby')).toBe('tabFocusTitle');
});
it('leaves the rows, Alt+N badges, tree and drag exactly as without Focus', () => {
const plainApp = makeApp({
...grouped(),
groups: grouped().groups.map((g) => ({ ...g, refs: g.refs.map(({ focus: _f, ...ref }) => ref) })),
ungrouped: [s('s1'), s('s3')],
});
const before = { rows: rows(), badges: badges(), html: document.getElementById('sessionTabs')!.innerHTML };
expect(section()?.hidden ?? true).toBe(true);
plainApp._tabLayoutEditor?.dispose();
makeApp();
expect(rows()).toEqual(before.rows);
expect(badges()).toEqual(before.badges);
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(before.html);
// Shortcuts are not rows and not tree items.
expect(
document.querySelectorAll('#tabFocus .session-tab, #tabFocus [role="treeitem"], #tabFocus .tab-number')
).toHaveLength(0);
expect(document.getElementById('sessionTabs')!.getAttribute('role')).toBe('tree');
expect(document.querySelectorAll('#sessionTabs [role="treeitem"][tabindex="0"]')).toHaveLength(1);
// One tab stop in the Focus list too, and no id appears twice on the page.
expect(shortcuts().filter((item) => item.tabIndex === 0)).toHaveLength(1);
const ids = [...document.querySelectorAll('[id]')].map((el) => el.id);
expect(ids.length).toBe(new Set(ids).size);
expect(shortcuts().every((item) => item.getAttribute('draggable') !== 'true')).toBe(true);
});
it('shows above the FLAT rail too, which stays a plain tablist', () => {
makeApp(flat());
expect(document.getElementById('sessionTabs')!.getAttribute('role')).toBe('tablist');
expect(rows()).toEqual(['s1', 's2', 's3', 'w1']);
expect(shortcutKeys()).toEqual(['session:s2']);
expect(section()!.hidden).toBe(false);
});
it('is absent off the vertical rail and with no layout', () => {
makeApp(grouped(), 'horizontal');
expect(section()?.hidden ?? true).toBe(true);
expect(shortcuts()).toHaveLength(0);
makeApp(null);
expect(section()?.hidden ?? true).toBe(true);
});
it('a click selects the tab, and the selected tab shortcut is aria-current', () => {
const app = makeApp();
expect(shortcuts().map((item) => item.getAttribute('aria-current'))).toEqual(['true', 'false', 'false']);
shortcuts()[2].click();
expect(app.selectSession).toHaveBeenCalledWith('s3', { forceReload: true });
shortcuts()[1].click();
expect(app.openWebview).toHaveBeenCalledWith('w1');
app.activeSessionId = 's3';
app._updateActiveTabImmediate('s3');
expect(shortcuts().map((item) => item.getAttribute('aria-current'))).toEqual(['false', 'false', 'true']);
app.activeWebviewId = 'w1';
app._updateActiveWebviewTab();
expect(shortcuts().map((item) => item.getAttribute('aria-current'))).toEqual(['false', 'true', 'false']);
});
it('carries the tab status and alert onto the shortcut', () => {
const app = makeApp();
expect(shortcuts()[0].querySelector('.tab-status')!.className).toBe('tab-status busy');
app.tabAlerts.set('s3', 'action');
app._renderSessionTabsImmediate();
expect(shortcuts()[2].classList.contains('tab-alert-action')).toBe(true);
});
it('a closed session drops out harmlessly, and the section hides when nothing is left', () => {
const app = makeApp(flat());
app.sessions.delete('s2');
app.sessionOrder = ['s1', 's3'];
app._renderSessionTabsImmediate();
expect(shortcuts()).toHaveLength(0);
expect(section()!.hidden).toBe(true);
});
it('repaints when another device changes a flag (not a structural change)', () => {
const app = makeApp();
app._applyTabLayout({ ...grouped(9), ungrouped: [s('s1', { focus: true }), s('s3')] });
expect(shortcutKeys()).toEqual(['session:s2', 'webview:w1', 'session:s1']);
});
});
describe('adding and removing', () => {
it('row menu offers Add to Focus, and choosing it PUTs the whole layout with the flag', async () => {
const puts = installFetch();
const app = makeApp();
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
expect(menuLabels()[1]).toBe('Add to Focus');
clickMenu('Add to Focus');
// Optimistic, and the row did not move.
expect(shortcutKeys()).toEqual(['session:s2', 'webview:w1', 'session:s1', 'session:s3']);
expect(rows()).toEqual(['s2', 'w1', 's1', 's3']);
await flush();
expect(puts).toHaveLength(1);
expect(puts[0].baseVersion).toBe(8);
expect(focusedIn(puts[0].layout)).toEqual(['session:s2', 'webview:w1', 'session:s1', 'session:s3']);
});
it('a focused row offers Remove from Focus', async () => {
const puts = installFetch();
const app = makeApp(flat());
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).toEqual(['Session options', 'Remove from Focus', 'Move to new group', 'Close session']);
clickMenu('Remove from Focus');
await flush();
expect(focusedIn(puts[0].layout)).toEqual([]);
expect(section()!.hidden).toBe(true);
});
it('the strip menu (horizontal) has no Focus entry', () => {
const app = makeApp(grouped(), 'horizontal');
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
expect(menuLabels()).toEqual(['Session options', 'Close session']);
});
it('a web tab: right-click in the rail opens its menu with the Focus entry; elsewhere the native menu stays', async () => {
const puts = installFetch();
const app = makeApp(flat());
const web = document.querySelector<HTMLElement>('#sessionTabs .session-tab[data-webview-id="w1"]')!;
// The row markup wires the handler (inline handlers do not run under JSDOM;
// the Chromium test right-clicks for real).
expect(web.getAttribute('oncontextmenu')).toBe('app.handleWebviewTabContextMenu(event, "w1")');
const event = new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'currentTarget', { value: web });
app.handleWebviewTabContextMenu(event, 'w1');
expect(event.defaultPrevented).toBe(true);
expect(menuLabels()).toEqual(['Web tab settings', 'Add to Focus', 'Move to new group']);
clickMenu('Add to Focus');
await flush();
expect(focusedIn(puts[0].layout)).toEqual(['session:s2', 'webview:w1']);
const strip = makeApp(flat(), 'horizontal');
const native = new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true });
expect(strip.handleWebviewTabContextMenu(native, 'w1')).toBe(false);
expect(native.defaultPrevented).toBe(false);
expect(document.querySelectorAll('.tab-rail-action-menu')).toHaveLength(0);
});
it('right-click on a shortcut offers Remove from Focus only', async () => {
const puts = installFetch();
makeApp();
shortcuts()[0].dispatchEvent(new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
expect(menuLabels()).toEqual(['Remove from Focus']);
clickMenu('Remove from Focus');
await flush();
expect(focusedIn(puts[0].layout)).toEqual(['webview:w1', 'session:s3']);
// The real row is still there, in its group.
expect(row('s2').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx');
});
});
describe('collapse and keyboard', () => {
it('collapse is per-device, survives a reload, and is not a group id', () => {
const app = makeApp();
document.getElementById('tabFocusToggle')!.click();
expect(document.getElementById('tabFocusList')!.hidden).toBe(true);
expect(document.getElementById('tabFocusToggle')!.getAttribute('aria-expanded')).toBe('false');
expect(win.localStorage.getItem('codeman:tab-focus-collapsed')).toBe('true');
expect(JSON.parse(win.localStorage.getItem('codeman:tab-groups-collapsed') || '[]')).toEqual([]);
expect(app.collapsedTabGroupIds.size).toBe(0);
makeApp();
expect(document.getElementById('tabFocusList')!.hidden).toBe(true);
document.getElementById('tabFocusToggle')!.click();
expect(document.getElementById('tabFocusList')!.hidden).toBe(false);
});
it('Up/Down/Home/End walk the shortcuts with one tab stop; the tree is untouched', () => {
makeApp();
const treeStop = document.querySelector('#sessionTabs [role="treeitem"][tabindex="0"]');
shortcuts()[0].focus();
key(shortcuts()[0], 'ArrowDown');
expect(document.activeElement).toBe(shortcuts()[1]);
key(shortcuts()[1], 'End');
expect(document.activeElement).toBe(shortcuts()[2]);
key(shortcuts()[2], 'ArrowDown');
expect(document.activeElement).toBe(shortcuts()[0]);
key(shortcuts()[0], 'ArrowUp');
expect(document.activeElement).toBe(shortcuts()[2]);
expect(shortcuts().map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
expect(document.querySelector('#sessionTabs [role="treeitem"][tabindex="0"]')).toBe(treeStop);
});
it('keeps keyboard focus on the same shortcut across a repaint', () => {
const app = makeApp();
shortcuts()[1].focus();
app.tabAlerts.set('s2', 'idle');
app._renderSessionTabsImmediate();
expect(document.activeElement).toBe(shortcuts()[1]);
});
});
describe('zh-CN', () => {
it('translates every Focus string, and leaves a shortcut name alone', () => {
const I18N = read('i18n.js');
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as any).CodemanI18n;
api.configure({ language: 'zh-CN' });
const strings = ['Focus', 'Add to Focus', 'Remove from Focus', 'Focus actions'];
const untranslated = strings.filter((text) => api.t(text) === text || /[A-Za-z]/.test(api.t(text)));
expect(untranslated).toEqual([]);
// Each is in the dictionary exactly once (a repeated key silently wins).
for (const text of strings) {
const keyPattern = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expect((I18N.match(new RegExp(`^\\s*(?:'${keyPattern}'|${keyPattern}):`, 'gm')) ?? []).length).toBe(1);
}
// The section in the live DOM: heading translated, the user's names not.
makeApp();
dom.window.document.body.innerHTML = section()!.outerHTML;
api.start();
const translated = dom.window.document.getElementById('tabFocusTitle')!.textContent;
expect(translated).toBe('焦点');
expect(
[...dom.window.document.querySelectorAll('.tab-focus-item')].map((b: any) => b.getAttribute('aria-label'))
).toEqual(['Two <b>', 'Dashboard', 'Three']);
dom.window.close();
});
});