Merge pull request #582: feat(tabs): pin tabs to a Focus section on the vertical rail

Conflicts resolved against #528:
- CLAUDE.md Owner tab layouts: #582's Focus sentences and #528's
  tabGroupId / spawnedTabsFollowParent sentences are both kept.
- i18n.js: both features' ZH_CN entries kept.
- tab-layout-browser.js: the section-model JSDoc documents #528's
  `options.parents` and #582's `focusRefs` return field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:13:03 +02:00
commit a6ce52d3e7
15 files changed
+1819 -16

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview The Focus flag in the browser's tab-layout mirror (tab-layout-browser.js).
*
* The browser writes WHOLE layouts back (`PUT /api/tab-layout`), so any field
* its defensive copy drops is erased on the next save, whoever made it. These
* tests pin that `focus: true` is carried like `placement`, that `setFocus` is a
* named operation that replays (and drops cleanly) through the edit coordinator,
* that a 409 rebase keeps both our flag and one set elsewhere, that the focused
* refs are exposed for rendering (with or without named groups), and the
* per-device collapse storage.
*
* Port: none (vm-loaded module).
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const SOURCE = readFileSync(join(process.cwd(), 'src/web/public/tab-layout-browser.js'), '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[];
};
function loadHelper() {
const context = vm.createContext({ window: {}, globalThis: {}, setTimeout, clearTimeout });
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
return (context.window as any).CodemanTabLayout;
}
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 plain = (value: unknown) => JSON.parse(JSON.stringify(value));
const base = (version = 5): Layout => ({
version,
updatedAt: '2026-10-09T00:00:00.000Z',
groups: [
{ id: 'g1', name: 'Core', refs: [s('a', { focus: true }), s('b')] },
{ id: 'g2', name: 'Ops', refs: [w('web', { focus: true })] },
],
ungrouped: [s('c', { placement: 'manual', focus: true }), s('d')],
});
const focused = (layout: Layout) =>
[...layout.groups.flatMap((g) => g.refs), ...layout.ungrouped]
.filter((ref) => ref.focus === true)
.map((ref) => `${ref.kind}:${ref.id}`);
describe('the defensive copy keeps focus', () => {
const h = loadHelper();
it('normalizeLayout keeps focus: true on session and web refs, next to placement', () => {
const copy = plain(h.normalizeLayout(base()));
expect(copy.groups[0].refs[0]).toEqual({ kind: 'session', id: 'a', focus: true });
expect(copy.groups[1].refs[0]).toEqual({ kind: 'webview', id: 'web', focus: true });
expect(copy.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual', focus: true });
expect(copy.ungrouped[1]).toEqual({ kind: 'session', id: 'd' });
});
it('drops anything but a literal true (the server only stores true)', () => {
const copy = plain(
h.normalizeLayout({
...base(),
ungrouped: [s('c', { focus: 'yes' as never }), s('d', { focus: false as never })],
})
);
expect(copy.ungrouped).toEqual([
{ kind: 'session', id: 'c' },
{ kind: 'session', id: 'd' },
]);
});
it('every existing operation keeps the flag on the refs it touches', () => {
let layout = base();
layout = h.applyOperation(layout, { type: 'renameGroup', groupId: 'g1', name: 'Front' });
layout = h.applyOperation(layout, { type: 'reorderGroup', groupId: 'g2', index: 0 });
layout = h.applyOperation(layout, { type: 'moveRef', ref: s('a'), groupId: 'g2', index: 1, parents: {} });
expect(plain(layout).groups[0].refs).toEqual([w('web', { focus: true }), s('a', { focus: true })]);
layout = h.applyOperation(layout, { type: 'deleteGroup', groupId: 'g2' });
expect(focused(plain(layout))).toEqual(['session:c', 'webview:web', 'session:a']);
});
it('a moved lineage block keeps each member flag, and the head turns manual', () => {
const layout: Layout = {
...base(),
groups: [{ id: 'g1', name: 'Core', refs: [s('p', { focus: true }), s('kid', { focus: true })] }],
ungrouped: [],
};
const moved = plain(
h.applyOperation(layout, { type: 'moveRef', ref: s('p'), groupId: null, parents: { kid: 'p', p: 'gp' } })
);
expect(moved.ungrouped).toEqual([s('p', { focus: true, placement: 'manual' }), s('kid', { focus: true })]);
});
});
describe('setFocus operation', () => {
const h = loadHelper();
it('adds and removes the flag on one ref and moves nothing', () => {
const added = plain(h.applyOperation(base(), { type: 'setFocus', ref: s('d'), focused: true }));
expect(focused(added)).toEqual(['session:a', 'webview:web', 'session:c', 'session:d']);
const removed = plain(h.applyOperation(added, { type: 'setFocus', ref: s('c'), focused: false }));
expect(removed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual' });
expect(removed.groups.map((g: any) => g.refs.map((r: Ref) => r.id))).toEqual([['a', 'b'], ['web']]);
expect(h.contentKey(h.applyOperation(added, { type: 'setFocus', ref: s('d'), focused: true }))).toBe(
h.contentKey(added)
);
});
it('refuses an unknown ref, a bad ref and a non-boolean state (so a replay drops it)', () => {
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: s('gone'), focused: true })).toThrow(/unknown row/);
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: { kind: 'x', id: 'a' }, focused: true })).toThrow();
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: s('a'), focused: 'yes' })).toThrow(/Focus/);
});
});
describe('edit coordinator with focus', () => {
const h = loadHelper();
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function controlledPut() {
const calls: Array<{ request: any; resolve: (value: any) => void }> = [];
const put = vi.fn((request: any) => new Promise((resolve) => calls.push({ request: plain(request), resolve })));
return { put, calls };
}
function makeEditor(put: any, extra: Record<string, unknown> = {}) {
const applied: any[] = [];
const editor = h.createEditCoordinator({
initialLayout: base(),
put,
applyLayout: (layout: any, meta: any) => applied.push({ layout: plain(layout), meta }),
schedule: (fn: () => void) => setTimeout(fn, 0),
cancel: (handle: any) => clearTimeout(handle),
...extra,
});
return { editor, applied };
}
it('sends setFocus in the whole-layout PUT, and an unrelated edit keeps every other flag', async () => {
const { put, calls } = controlledPut();
const { editor } = makeEditor(put);
editor.enqueue({ type: 'setFocus', ref: s('b'), focused: true });
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Web' });
await settle();
expect(calls).toHaveLength(1);
expect(focused(calls[0].request.layout)).toEqual(['session:a', 'session:b', 'webview:web', 'session:c']);
});
it('replays setFocus onto the 409 layout and keeps a flag set elsewhere', async () => {
const { put, calls } = controlledPut();
const { editor, applied } = makeEditor(put);
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
await settle();
// Elsewhere: someone unpinned `a`, pinned `b` and renamed Core (version 9).
const server: Layout = {
...base(9),
groups: [
{ id: 'g1', name: 'Front', refs: [s('a'), s('b', { focus: true })] },
{ id: 'g2', name: 'Ops', refs: [w('web', { focus: true })] },
],
};
calls[0].resolve({ ok: false, status: 409, layout: server });
await settle();
expect(calls).toHaveLength(2);
expect(calls[1].request.baseVersion).toBe(9);
expect(calls[1].request.layout.groups[0].name).toBe('Front');
expect(focused(calls[1].request.layout)).toEqual(['session:b', 'webview:web', 'session:c', 'session:d']);
calls[1].resolve({ ok: true, status: 200, layout: { ...calls[1].request.layout, version: 10 } });
await settle();
expect(focused(applied.at(-1).layout)).toEqual(['session:b', 'webview:web', 'session:c', 'session:d']);
expect(editor.hasPending()).toBe(false);
});
it('drops a setFocus whose ref vanished in the conflict, and reports it once', async () => {
const { put, calls } = controlledPut();
const reportError = vi.fn();
const { editor, applied } = makeEditor(put, { reportError });
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
await settle();
calls[0].resolve({
ok: false,
status: 409,
layout: { ...base(9), ungrouped: [s('c', { placement: 'manual', focus: true })] },
});
await settle();
expect(calls).toHaveLength(1);
expect(reportError).toHaveBeenCalledTimes(1);
expect(focused(applied.at(-1).layout)).toEqual(['session:a', 'webview:web', 'session:c']);
});
it('adopting an external layout keeps its flags under a pending setFocus', () => {
const { editor } = makeEditor(() => new Promise(() => undefined), { schedule: () => 1, cancel: () => undefined });
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
const external = { ...base(7), ungrouped: [s('c', { placement: 'manual' }), s('d')] };
expect(editor.adoptExternal(external)).toBe(true);
expect(focused(plain(editor.getLayout()))).toEqual(['session:a', 'webview:web', 'session:d']);
});
});
describe('focused refs for the rail', () => {
const h = loadHelper();
const options = { liveSessionIds: ['a', 'b', 'c', 'd'], openWebviewIds: ['web'] };
it('focusRefs lists renderable focused refs in stored order, with or without groups', () => {
expect(plain(h.focusRefs(base(), options))).toEqual([s('a'), w('web'), s('c')]);
const flat = { ...base(), groups: [], ungrouped: [s('d', { focus: true }), s('c'), w('web', { focus: true })] };
expect(plain(h.focusRefs(flat, options))).toEqual([s('d'), w('web')]);
// The flat layout still projects to null: the rail itself stays the flat list.
expect(h.project(flat, options)).toBeNull();
});
it('a gone session or a closed web tab drops out of Focus harmlessly', () => {
expect(plain(h.focusRefs(base(), { liveSessionIds: ['b', 'c'], openWebviewIds: [] }))).toEqual([s('c')]);
expect(plain(h.focusRefs(null, options))).toEqual([]);
expect(
plain(
h.focusRefs({ ...base(), ungrouped: [s('c', { focus: true }), s('c', { focus: true })], groups: [] }, options)
)
).toEqual([s('c')]);
});
it('project() exposes the same focusRefs on a grouped layout', () => {
const projection = h.project(base(), options);
expect(plain(projection.focusRefs)).toEqual([s('a'), w('web'), s('c')]);
// Shortcuts are not rows: the grouped rows are exactly what they were.
expect(plain(projection.visibleRefs)).toEqual([s('a'), s('b'), w('web'), s('c'), s('d')]);
});
});
describe('Focus collapse is per-device storage', () => {
const h = loadHelper();
const memory = () => {
const data = new Map<string, string>();
return {
data,
getItem: (key: string) => (data.has(key) ? data.get(key)! : null),
setItem: (key: string, value: string) => void data.set(key, value),
};
};
it('round-trips under its own key and never touches the group collapse list', () => {
const storage = memory();
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: false, ok: true });
expect(h.saveFocusCollapsed(storage, true)).toEqual({ collapsed: true, ok: true });
expect(storage.data.get('codeman:tab-focus-collapsed')).toBe('true');
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: true, ok: true });
expect(storage.data.has('codeman:tab-groups-collapsed')).toBe(false);
// Group collapse GC (ids of groups that no longer exist) leaves it alone.
h.loadCollapsedGroupIds(storage, []);
expect(h.loadFocusCollapsed(storage).collapsed).toBe(true);
});
it('a junk value reads as expanded, and a throwing store reports failure', () => {
const storage = memory();
storage.setItem('codeman:tab-focus-collapsed', '{nope');
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: false, ok: true });
const broken = {
getItem: () => {
throw new Error('denied');
},
setItem: () => {
throw new Error('denied');
},
};
expect(h.loadFocusCollapsed(broken)).toEqual({ collapsed: false, ok: false });
expect(h.saveFocusCollapsed(broken, true)).toEqual({ collapsed: false, ok: false });
});
});
+426
View File
@@ -0,0 +1,426 @@
/**
* @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();
});
});
+268
View File
@@ -0,0 +1,268 @@
/**
* @fileoverview Real-Chromium coverage for the Focus section of the vertical rail.
*
* What DOM emulation cannot answer: a real right-click reaching the inline
* handlers, the section actually painting above the list (layout, not just DOM
* order), the browser's own Tab order across the Focus toggle, the Focus list
* and the grouped tree (one stop each), and a pointer drag on a shortcut doing
* nothing. The shipping app.js, tab-layout-browser.js, tab-rail-resize.js,
* api-client.js, webview-tabs.js and styles.css are loaded into a page;
* PUT /api/tab-layout is answered by a route that records bodies.
*
* Port: none (page.route on a fake origin, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, beforeEach, 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');
const GROUPED = {
version: 4,
updatedAt: '2026-10-09T00:00:00.000Z',
groups: [
{
id: 'gx',
name: 'Core',
refs: [
{ kind: 'session', id: 'two', focus: true },
{ kind: 'webview', id: 'web' },
],
},
{ id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 'three' }] },
],
ungrouped: [{ kind: 'session', id: 'one' }],
};
const FLAT = {
version: 4,
updatedAt: '2026-10-09T00:00:00.000Z',
groups: [],
ungrouped: [
{ kind: 'session', id: 'one' },
{ kind: 'session', id: 'two', focus: true },
{ kind: 'session', id: 'three' },
{ kind: 'webview', id: 'web' },
],
};
const focusedIn = (layout: any): string[] =>
[...layout.groups.flatMap((g: any) => g.refs), ...layout.ungrouped]
.filter((ref: any) => ref.focus === true)
.map((ref: any) => `${ref.kind}:${ref.id}`);
describe('Focus section in Chromium', () => {
let browser: Browser;
let page: Page;
let puts: any[] = [];
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
await page.route('http://codeman.test/', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
);
await page.route('http://codeman.test/api/tab-layout', async (route) => {
const request = route.request();
if (request.method() !== 'PUT') return route.fulfill({ status: 404, body: '' });
const body = JSON.parse(request.postData() || '{}');
puts.push(body);
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ success: true, data: { layout: { ...body.layout, version: body.baseVersion + 1 } } }),
});
});
await page.goto('http://codeman.test/');
await page.setContent(`<!doctype html>
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
<head><style>${read('styles.css')}</style></head>
<body>
<button id="before">before</button>
<main class="main" style="width:100%;height:760px">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation" style="width:280px"></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('tab-rail-resize.js') });
await page.addScriptTag({ content: read('api-client.js') });
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' }],
['two', { id: 'two', name: 'Two', status: 'busy' }],
['three', { id: 'three', name: 'Three', status: 'idle' }],
]);
app.sessionOrder = ['one', 'two', 'three'];
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
app.webviewOrder = ['web'];
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
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;
app.loadAppSettingsFromStorage = () => ({});
app.showToast = () => undefined;
app.showWebviewModal = () => undefined;
app.selectSession = (id: string) => {
w.__activation = `session:${id}`;
};
app.openWebview = (id: string) => {
w.__activation = `webview:${id}`;
};
w.__setApp(app);
w.__app = app;
});
});
afterAll(async () => browser.close());
async function load(layout: unknown) {
puts = [];
await page.evaluate((next) => {
const w = window as any;
localStorage.clear();
document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove());
document.getElementById('sessionTabs')?.remove();
document.getElementById('tabFocus')?.remove();
document
.getElementById('tabRail')!
.insertAdjacentHTML(
'afterbegin',
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
);
w.__app._tabKeydownHandler = null;
w.__app._tabLayoutEditor?.dispose();
w.__app._tabLayoutEditor = null;
w.__app.tabLayout = null;
w.__app._tabFocusCollapsed = undefined;
w.__app._lastTabFocusHtml = '';
w.__app._tabFocusStopKey = undefined;
w.__app.activeSessionId = 'one';
w.__app.activeWebviewId = null;
w.__app._lastTabGroupStructureKey = null;
w.__app._fullRenderSessionTabs();
w.__app._applyTabLayout(next);
w.__activation = null;
}, layout);
await page.mouse.move(1, 1);
}
const settled = async () => {
await page.waitForTimeout(80);
await page.evaluate(() => new Promise((r) => setTimeout(r, 20)));
};
const box = async (selector: string) => (await page.locator(selector).boundingBox())!;
beforeEach(async () => load(GROUPED));
it('paints the section above the list, and a click on a shortcut selects its tab', async () => {
const section = await box('#tabFocus');
const list = await box('#sessionTabs');
expect(section.height).toBeGreaterThan(20);
expect(section.y + section.height).toBeLessThanOrEqual(list.y + 1);
expect(await page.locator('#tabFocus .tab-focus-name').allTextContents()).toEqual(['Two']);
// The real row is still in its group.
expect(await page.locator('[data-tab-group-id="gx"] .session-tab[data-id="two"]').count()).toBe(1);
await page.click('#tabFocus .tab-focus-item');
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
});
it('a real right-click on a web tab in the FLAT rail pins it, and its shortcut opens it', async () => {
await load(FLAT);
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
await page.click('#sessionTabs .session-tab[data-webview-id="web"]', { button: 'right' });
const labels = await page.locator('.tab-rail-action-menu button').allTextContents();
expect(labels).toEqual(['Web tab settings', 'Add to Focus', 'Move to new group']);
await page.click('.tab-rail-action-menu button:text-is("Add to Focus")');
await settled();
expect(puts).toHaveLength(1);
expect(focusedIn(puts[0].layout)).toEqual(['session:two', 'webview:web']);
expect(await page.locator('#tabFocus .tab-focus-name').allTextContents()).toEqual(['Two', 'Web']);
await page.click('#tabFocus .tab-focus-item[data-focus-key="webview:web"]');
expect(await page.evaluate(() => (window as any).__activation)).toBe('webview:web');
});
it('a real right-click on a shortcut removes it; the row stays', async () => {
await page.click('#tabFocus .tab-focus-item', { button: 'right' });
expect(await page.locator('.tab-rail-action-menu button').allTextContents()).toEqual(['Remove from Focus']);
await page.click('.tab-rail-action-menu button:text-is("Remove from Focus")');
await settled();
expect(focusedIn(puts[0].layout)).toEqual([]);
expect(await page.locator('#tabFocus').isHidden()).toBe(true);
expect(await page.locator('[data-tab-group-id="gx"] .session-tab[data-id="two"]').count()).toBe(1);
});
it('Tab walks: Focus toggle, ONE Focus list stop, then ONE tree stop', async () => {
await page.evaluate(() => {
const w = window as any;
w.__app._applyTabLayout({
...w.__app.tabLayout,
version: 5,
ungrouped: [{ kind: 'session', id: 'one', focus: true }],
});
});
expect(await page.locator('#tabFocus .tab-focus-item').count()).toBe(2);
await page.focus('#before');
const stops: string[] = [];
for (let i = 0; i < 3; i++) {
await page.keyboard.press('Tab');
stops.push(
await page.evaluate(() => {
const el = document.activeElement as HTMLElement;
return el.id || el.dataset.focusKey || el.dataset.tabGroupHeader || el.dataset.id || el.tagName;
})
);
}
// 'one' is the active session: its shortcut holds the list stop, its row the tree stop.
expect(stops).toEqual(['tabFocusToggle', 'session:one', 'one']);
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
await page.keyboard.press('ArrowUp');
expect(await page.evaluate(() => (document.activeElement as HTMLElement).dataset.focusKey)).toBe('session:two');
await page.keyboard.press('Enter');
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
});
it('the toggle collapses per device, and a drag from a shortcut moves nothing', async () => {
await page.click('#tabFocusToggle');
expect(await page.locator('#tabFocusList').isHidden()).toBe(true);
expect(await page.evaluate(() => localStorage.getItem('codeman:tab-focus-collapsed'))).toBe('true');
await page.click('#tabFocusToggle');
const a = await box('#tabFocus .tab-focus-item');
const b = await box('[data-tab-group-header="gy"]');
await page.mouse.move(a.x + 20, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 8 });
await page.mouse.up();
await settled();
expect(puts).toHaveLength(0);
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into').count()).toBe(0);
});
});
+4 -1
View File
@@ -627,13 +627,14 @@ describe('row actions in the vertical rail', () => {
installFetch();
const flat = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
flat.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
expect(menuLabels()).toEqual(['Session options', 'Move to new group', 'Close session']);
expect(menuLabels()).toEqual(['Session options', 'Add to Focus', 'Move to new group', 'Close session']);
flat.closeTabRailActionMenu();
const app = makeApp();
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).toEqual([
'Session options',
'Add to Focus',
'Move down',
'Move to "Later"',
'Move to Ungrouped',
@@ -660,6 +661,7 @@ describe('row actions in the vertical rail', () => {
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).toEqual([
'Session options',
'Add to Focus',
'Move to "ungrouped"',
'Move to Ungrouped',
'Move to new group',
@@ -736,6 +738,7 @@ describe('web tab rows', () => {
key(web, 'F10', { shiftKey: true });
expect(menuLabels()).toEqual([
'Web tab settings',
'Add to Focus',
'Move up',
'Move to "Later"',
'Move to Ungrouped',
+358
View File
@@ -0,0 +1,358 @@
/**
* @fileoverview The Focus flag on owner tab layout refs, server side.
*
* A session or web-tab ref may carry `focus: true` (src/tab-layout.ts): the
* browser then draws a shortcut to it in a pinned "Focus" section at the top of
* the vertical rail, while the ref itself stays where it is stored. These tests
* pin that the flag is parsed strictly, and that nothing on the server drops it:
* normalization, the pure edit helpers, TabLayoutService (PUT, legacy order,
* deletions, reconciliation), the legacy-order projection, and StateStore's
* persistence across a reload. Multi-user owner scoping is unchanged.
*
* Port: none (Fastify inject, temp state file).
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StateStore } from '../src/state-store.js';
import { applyLegacySessionRank } from '../src/tab-layout-legacy-order.js';
import { normalizeOrMigrateOwnerTabLayout } from '../src/tab-layout-persistence.js';
import { TabLayoutService } from '../src/tab-layout-service.js';
import {
deleteGroup,
followParent,
materializeOrphans,
moveRef,
normalizeTabLayout,
setTabFocus,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
type TabRefMetadata,
} from '../src/tab-layout.js';
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
import { registerTabLayoutRoutes } from '../src/web/routes/tab-layout-routes.js';
const at = '2026-10-09T00:00:00.000Z';
const session = (id: string, extra: Record<string, unknown> = {}) => ({ kind: 'session' as const, id, ...extra });
const webview = (id: string, extra: Record<string, unknown> = {}) => ({ kind: 'webview' as const, id, ...extra });
const meta = (
id: string,
order: number,
extra: Partial<TabRefMetadata> = {},
kind: 'session' | 'webview' = 'session'
): TabRefMetadata => ({ kind, id, ownerValid: true, visible: true, order, ...extra });
/** Every ref that carries the flag, as `kind:id`, in stored order. */
const focused = (layout: TabLayout): string[] =>
[...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]
.filter((ref) => ref.focus === true)
.map((ref) => `${ref.kind}:${ref.id}`);
const base = (): TabLayout => ({
version: 3,
updatedAt: at,
groups: [
{ id: 'g1', name: 'Core', refs: [session('a', { focus: true }), webview('w', { focus: true })] },
{ id: 'g2', name: 'Ops', refs: [session('b')] },
],
ungrouped: [session('c', { placement: 'manual', focus: true }), session('d')],
});
const metadata: TabRefMetadata[] = [
meta('a', 0),
meta('b', 1),
meta('c', 2),
meta('d', 3),
meta('w', 4, {}, 'webview'),
];
describe('model: parsing the focus flag', () => {
it('keeps focus: true on session and webview refs, beside placement', () => {
const parsed = validateTabLayout(base());
expect(parsed.groups[0].refs).toEqual([session('a', { focus: true }), webview('w', { focus: true })]);
expect(parsed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual', focus: true });
expect(parsed.ungrouped[1]).toEqual({ kind: 'session', id: 'd' });
});
it.each([false, 'true', 1, null, {}])('rejects focus: %j (only true or absent is valid)', (value) => {
const input = { ...base(), ungrouped: [session('d', { focus: value })] };
expect(() => validateTabLayout(input)).toThrow(TabLayoutValidationError);
expect(() => validateTabLayout(input)).toThrow(/focus/);
});
it('a ref with no focus key gets no focus key back (absent, never false)', () => {
const parsed = validateTabLayout({ ...base(), groups: [], ungrouped: [session('d')] });
expect(Object.keys(parsed.ungrouped[0]).sort()).toEqual(['id', 'kind']);
});
});
describe('model: the flag survives every pure edit', () => {
it('normalizeTabLayout keeps focus, including on lineage-reordered children', () => {
const layout: TabLayout = {
version: 1,
updatedAt: at,
groups: [],
ungrouped: [session('child', { focus: true }), session('parent')],
};
const normalized = normalizeTabLayout(layout, [meta('parent', 0), meta('child', 1, { parentSessionId: 'parent' })]);
// The child follows its parent now, and still carries the flag.
expect(normalized.ungrouped).toEqual([session('parent'), session('child', { focus: true })]);
});
it('normalizeTabLayout prunes a focused ref whose session is known to be gone', () => {
const normalized = normalizeTabLayout(base(), [...metadata, meta('a', 0, { ownerValid: false })].slice(1));
expect(focused(normalized)).toEqual(['webview:w', 'session:c']);
});
it('moveRef across groups keeps the moved ref and its followers focused', () => {
const withChild: TabLayout = {
...base(),
groups: [
{ id: 'g1', name: 'Core', refs: [session('a', { focus: true }), session('kid', { focus: true })] },
{ id: 'g2', name: 'Ops', refs: [session('b')] },
],
ungrouped: [],
};
const facts = [meta('a', 0), meta('kid', 1, { parentSessionId: 'a' }), meta('b', 2)];
const moved = moveRef(withChild, session('a'), { groupId: 'g2', index: 1 }, facts);
expect(moved.groups[1].refs).toEqual([
session('b'),
session('a', { focus: true }),
session('kid', { focus: true }),
]);
});
it('followParent clears manual placement but keeps focus', () => {
const layout: TabLayout = {
version: 1,
updatedAt: at,
groups: [],
ungrouped: [session('parent'), session('other'), session('kid', { placement: 'manual', focus: true })],
};
const facts = [meta('parent', 0), meta('other', 1), meta('kid', 2, { parentSessionId: 'parent' })];
const result = followParent(layout, session('kid'), facts);
expect(result.ungrouped).toEqual([session('parent'), session('kid', { focus: true }), session('other')]);
});
it('deleteGroup and materializeOrphans keep focus on the refs they move or pin', () => {
expect(focused(deleteGroup(base(), 'g1'))).toEqual(['session:c', 'session:a', 'webview:w']);
const orphaned = materializeOrphans(
{ ...base(), ungrouped: [session('p'), session('kid', { focus: true })] },
['p'],
[...metadata, meta('p', 5), meta('kid', 6, { parentSessionId: 'p' })]
);
expect(orphaned.ungrouped).toEqual([session('kid', { placement: 'manual', focus: true })]);
});
it('setTabFocus adds and removes the flag on one ref without moving anything', () => {
const added = setTabFocus(base(), session('b'), true);
expect(focused(added)).toEqual(['session:a', 'webview:w', 'session:b', 'session:c']);
const removed = setTabFocus(added, session('c'), false);
expect(removed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual' });
expect(removed.groups.map((group) => group.refs.map((ref) => ref.id))).toEqual([['a', 'w'], ['b']]);
// Idempotent both ways.
expect(setTabFocus(added, session('b'), true)).toEqual(added);
expect(setTabFocus(base(), session('d'), false)).toEqual(validateTabLayout(base()));
expect(() => setTabFocus(base(), session('nope'), true)).toThrow(/unknown ref/);
});
});
describe('legacy order projection', () => {
it('applyLegacySessionRank re-ranks focused sessions without dropping the flag', () => {
const ranked = applyLegacySessionRank(base(), ['d', 'c', 'b', 'a'], metadata);
expect(ranked.ungrouped).toEqual([session('d'), session('c', { placement: 'manual', focus: true })]);
expect(focused(ranked)).toEqual(['session:a', 'webview:w', 'session:c']);
});
it('migration of an existing layout keeps focus', () => {
const result = normalizeOrMigrateOwnerTabLayout({
owner: '@single',
layouts: { '@single': base() },
sessionOrder: [],
persistedSessions: [],
liveSessions: ['a', 'b', 'c', 'd'].map((id, index) => ({ id, createdAt: index })),
webviews: [{ id: 'w' }],
updatedAt: at,
});
expect(focused(result.layout)).toEqual(['session:a', 'webview:w', 'session:c']);
});
});
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
function createService(
options: {
layouts?: Record<string, TabLayout>;
live?: SessionFact[];
webviews?: Array<{ id: string; owner?: string }>;
} = {}
) {
const layouts = structuredClone(options.layouts ?? {});
const order: string[] = [];
const webviews = [...(options.webviews ?? [])];
const live = new Map((options.live ?? []).map((record) => [record.id, record]));
const store = {
getTabLayout: (owner: string) => structuredClone(layouts[owner] ?? null),
getTabLayouts: () => structuredClone(layouts),
getSessions: () => ({}),
getSessionOrder: () => [...order],
commitTabLayoutProjection: (
updates: Readonly<Record<string, TabLayout>>,
project: (latest: readonly string[]) => readonly string[]
) => {
const projected = [...project([...order])];
Object.assign(layouts, structuredClone(updates));
order.splice(0, order.length, ...projected);
return { layouts: structuredClone(updates), sessionOrder: [...order] };
},
};
const service = new TabLayoutService({
store: store as never,
sessions: live as never,
readWebviews: async () => webviews,
broadcast: vi.fn(),
broadcastSessionOrder: vi.fn(),
now: () => at,
});
service.markRestorationComplete();
return { service, layouts, live, webviews };
}
const liveFacts = (owner?: string): SessionFact[] =>
['a', 'b', 'c', 'd'].map((id, index) => ({ id, owner, createdAt: index }));
describe('TabLayoutService keeps focus', () => {
it('stores focus from a PUT and returns it from GET', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
const current = await h.service.get('@single');
expect(focused(current)).toEqual(['session:a', 'webview:w', 'session:c']);
const result = await h.service.put('@single', setTabFocus(current, session('d'), true), current.version);
expect(result.status).toBe('updated');
expect(focused(await h.service.get('@single'))).toEqual(['session:a', 'webview:w', 'session:c', 'session:d']);
});
it('keeps focus on a ref a PUT moves into another group', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
const current = await h.service.get('@single');
// The browser's moveRef: `a` (focused) leaves g1 for the end of g2.
const desired: TabLayout = {
...current,
groups: [
{ ...current.groups[0], refs: [webview('w', { focus: true })] },
{ ...current.groups[1], refs: [session('b'), session('a', { focus: true })] },
],
};
const result = await h.service.put('@single', desired, current.version);
expect(result.layout.groups[1].refs).toEqual([session('b'), session('a', { focus: true })]);
expect(focused(h.layouts['@single'])).toEqual(['webview:w', 'session:a', 'session:c']);
});
it('keeps focus through a legacy session-order PUT (reordering)', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
await h.service.putLegacyOrder({ owner: '@single', isAdmin: true }, ['d', 'c', 'b', 'a']);
expect(focused(h.layouts['@single'])).toEqual(['session:a', 'webview:w', 'session:c']);
expect(h.layouts['@single'].ungrouped.map((ref) => ref.id)).toEqual(['d', 'c']);
});
it('a deleted session takes its focus flag with it and leaves the others alone', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
await h.service.get('@single');
await h.service.runSessionDeletion([{ id: 'a' }], async () => {
h.live.delete('a');
});
expect(focused(h.layouts['@single'])).toEqual(['webview:w', 'session:c']);
});
it('a deleted web tab takes its focus flag with it', async () => {
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
await h.service.get('@single');
h.webviews.splice(0);
await h.service.webviewDeleted('@single', 'w');
expect(focused(h.layouts['@single'])).toEqual(['session:a', 'session:c']);
});
it('multi-user: focus is per owner, and a PUT naming a foreign ref is still refused', async () => {
const aliceLayout: TabLayout = {
version: 1,
updatedAt: at,
groups: [],
ungrouped: [session('a', { focus: true })],
};
const h = createService({
layouts: { alice: aliceLayout },
live: [
{ id: 'a', owner: 'alice', createdAt: 0 },
{ id: 'b', owner: 'bob', createdAt: 1 },
],
});
const bob = await h.service.get('bob');
expect(focused(bob)).toEqual([]);
expect(focused(await h.service.get('alice'))).toEqual(['session:a']);
await expect(
h.service.put('bob', { ...bob, ungrouped: [session('b'), session('a', { focus: true })] }, bob.version)
).rejects.toThrow(/not owned/);
await h.service.put('bob', setTabFocus(bob, session('b'), true), bob.version);
expect(focused(h.layouts.bob)).toEqual(['session:b']);
expect(focused(h.layouts.alice)).toEqual(['session:a']);
});
});
describe('persistence and the HTTP route', () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it('PUT /api/tab-layout with focus round-trips through GET and a StateStore reload', async () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-focus-'));
dirs.push(dir);
const file = join(dir, 'state.json');
writeFileSync(file, JSON.stringify({}));
const store = new StateStore(file);
const live = new Map(liveFacts().map((record) => [record.id, record]));
const service = new TabLayoutService({
store,
sessions: live,
readWebviews: async () => [],
broadcast: () => undefined,
broadcastSessionOrder: () => undefined,
now: () => at,
});
service.markRestorationComplete();
const app = Fastify({ logger: false });
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
installRouteErrorHandler(app);
await app.ready();
vi.stubEnv('CODEMAN_MULTIUSER', '0');
try {
const read = await app.inject({ method: 'GET', url: '/api/tab-layout' });
const layout = read.json().data.layout as TabLayout;
const put = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: layout.version, layout: setTabFocus(layout, session('b'), true) },
});
expect(put.statusCode).toBe(200);
expect(focused(put.json().data.layout)).toEqual(['session:b']);
const refused = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: {
baseVersion: put.json().data.layout.version,
layout: { ...put.json().data.layout, ungrouped: [session('a', { focus: false })] },
},
});
expect(refused.statusCode).toBe(400);
store.saveNow();
} finally {
await app.close();
vi.unstubAllEnvs();
}
// A fresh store over the same file: what a restart would read.
expect(focused(new StateStore(file).getTabLayout('@single')!)).toEqual(['session:b']);
});
});