Files
Codeman/test/tab-layout-editing.test.ts
T
Aamer Akhter 5fb56b0889 fix(tabs): put child follow behind a setting, stop unpinning children on order PUTs
Review fixes for "place new, child and web tabs into groups".

- Spawned tabs follow their parent only with the new synced setting
  spawnedTabsFollowParent (App Settings > Appearance > Tabs, off by default).
  TabLayoutService.sessionCreated() reads it fresh through a
  childrenFollowParent dep: off, a spawned session lands after its parent and
  is marked manual in the same write; on, it follows until moved by hand. The
  row menu shows the placement note and "Follow parent again" only when on.
  "New session" in a group menu is unaffected. zh-CN label and a
  Settings-Reference row.
- applyLegacySessionRank marks a child manual only when the requested order
  splits it from its parent's block. A PUT of the current order, or one that
  moves a family together, leaves every stored placement alone.
- The browser never echoes a create or delete through PUT /api/session-order,
  with or without groups: the layout service already re-projects the order and
  broadcasts it, and syncSessionOrder adopts it.
- The placement note in the row menu is plain text described by the menu, not
  a focusable menu item.
- A tab that first appears inside a collapsed group briefly marks that group's
  header.
- i18n skips .tab-name, so tab names are never translated.
- Tests: legacy-order cases for an unchanged order, a family reorder and a
  split; service cases for the setting and for order PUTs; the scheduled
  registration test now exercises register/undo; the grouped-create test binds
  port 0; one route case runs through the real layout service.
2026-10-09 20:34:37 -04:00

1437 lines
60 KiB
TypeScript

/**
* @fileoverview Editing the grouped vertical rail from the browser.
*
* Two halves:
* - the pure operation mirror + edit coordinator in tab-layout-browser.js
* (vm-loaded): each named operation, the PUT payload and version, ONE write
* in flight at a time, a 409 rebased onto the server's layout, and the
* drag-drop -> operation mapping;
* - the app.js wiring driven through the shipping CodemanApp inside JSDOM:
* row and group menus, inline group rename, menu dismissal, SSE deferral
* while a write is in flight, recovery of unsaved edits across a reload, and
* the flat rail staying byte-identical when no group exists.
*
* Port: none.
*/
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' };
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(read('tab-layout-browser.js'), context, { filename: 'tab-layout-browser.js' });
return (context.window as any).CodemanTabLayout;
}
const s = (id: string): Ref => ({ kind: 'session', id });
const w = (id: string): Ref => ({ kind: 'webview', id });
const base = (version = 5): Layout => ({
version,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [
{ id: 'g1', name: 'Core', refs: [s('a'), s('b')] },
{ id: 'g2', name: 'Ops', refs: [w('web')] },
],
ungrouped: [s('c'), s('d')],
});
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
const keys = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
describe('operations', () => {
const h = loadHelper();
it('creates, renames, reorders and deletes groups without mutating the input', () => {
const input = base();
let next = h.applyOperation(input, { type: 'createGroup', id: 'g3', name: ' New ', index: 1 });
expect(next.groups.map((g: any) => [g.id, g.name])).toEqual([
['g1', 'Core'],
['g3', 'New'],
['g2', 'Ops'],
]);
// Replaying a create (recovery after reload) is a no-op, not a duplicate.
expect(plain(h.applyOperation(next, { type: 'createGroup', id: 'g3', name: 'New' }))).toEqual(plain(next));
next = h.applyOperation(next, { type: 'renameGroup', groupId: 'g3', name: 'Renamed' });
expect(next.groups[1].name).toBe('Renamed');
next = h.applyOperation(next, { type: 'reorderGroup', groupId: 'g1', index: 2 });
expect(next.groups.map((g: any) => g.id)).toEqual(['g3', 'g2', 'g1']);
next = h.applyOperation(next, { type: 'deleteGroup', groupId: 'g1' });
expect(next.groups.map((g: any) => g.id)).toEqual(['g3', 'g2']);
// Its rows return to Ungrouped, in order, after what was already there.
expect(keys(next.ungrouped)).toEqual(['session:c', 'session:d', 'session:a', 'session:b']);
expect(plain(input)).toEqual(plain(base()));
});
it('rejects an operation the server would refuse, before any PUT', () => {
expect(() => h.applyOperation(base(), { type: 'createGroup', id: 'x', name: ' ' })).toThrow();
expect(() => h.applyOperation(base(), { type: 'createGroup', id: 'x', name: 'n'.repeat(61) })).toThrow();
expect(() => h.applyOperation(base(), { type: 'renameGroup', groupId: 'nope', name: 'x' })).toThrow();
expect(() => h.applyOperation(base(), { type: 'moveRef', ref: s('zz'), groupId: null, index: 0 })).toThrow();
expect(() => h.applyOperation(base(), { type: 'moveRef', ref: s('a'), groupId: 'nope', index: 0 })).toThrow();
const full = { ...base(), groups: Array.from({ length: 32 }, (_, i) => ({ id: `g${i}`, name: 'x', refs: [] })) };
expect(() => h.applyOperation(full, { type: 'createGroup', id: 'one-more', name: 'x' })).toThrow(/limit/);
});
it('moves a row with the sessions that follow it, and marks a hand-moved child manual', () => {
const layout = { ...base(), ungrouped: [s('c'), s('child'), s('d')] };
const parents = { child: 'c' };
const moved = h.applyOperation(layout, { type: 'moveRef', ref: s('c'), groupId: 'g1', index: 1, parents });
expect(keys(moved.groups[0].refs)).toEqual(['session:a', 'session:c', 'session:child', 'session:b']);
expect(keys(moved.ungrouped)).toEqual(['session:d']);
const child = h.applyOperation(layout, { type: 'moveRef', ref: s('child'), groupId: 'g2', index: 0, parents });
expect(child.groups[1].refs[0]).toEqual({ kind: 'session', id: 'child', placement: 'manual' });
// ...and manual placement survives normalization (it is written back whole).
expect(h.normalizeLayout(child).groups[1].refs[0].placement).toBe('manual');
});
});
describe('child sessions: follow and hand placement', () => {
const h = loadHelper();
const lineage = (): Layout => ({
version: 5,
updatedAt: '',
groups: [
{ id: 'g1', name: 'Core', refs: [s('p'), s('kid'), s('x')] },
{ id: 'g2', name: 'Ops', refs: [{ kind: 'session', id: 'moved', placement: 'manual' }, s('moved-kid')] },
],
ungrouped: [s('u'), { kind: 'session', id: 'orphan', placement: 'manual' }],
});
const parents = { kid: 'p', moved: 'p', 'moved-kid': 'moved', orphan: 'gone' };
it('describes each child: following, placed by hand, or with no parent to follow', () => {
const state = (id: string, extra = {}) => plain(h.placementState(lineage(), s(id), { ...parents, ...extra }));
expect(state('kid')).toEqual({ state: 'inherited', parentId: 'p', canFollow: false });
expect(state('moved')).toEqual({ state: 'manual', parentId: 'p', canFollow: true });
expect(state('orphan')).toEqual({ state: 'dangling', parentId: 'gone', canFollow: false });
expect(state('p')).toBeNull();
expect(state('x', { x: 'u', u: 'x' })).toMatchObject({ state: 'cycle', canFollow: false });
});
it('Follow parent again clears the hand placement and puts the child (and its followers) after its parent', () => {
const next = h.applyOperation(lineage(), { type: 'followParent', ref: s('moved'), parents });
expect(keys(next.groups[0].refs)).toEqual([
'session:p',
'session:kid',
'session:moved',
'session:moved-kid',
'session:x',
]);
expect(next.groups[0].refs.find((ref: Ref) => ref.id === 'moved').placement).toBeUndefined();
expect(next.groups[1].refs).toEqual([]);
});
it('refuses to follow a parent that is gone or loops, and leaves a following child as it is', () => {
expect(() => h.applyOperation(lineage(), { type: 'followParent', ref: s('orphan'), parents })).toThrow();
expect(() =>
h.applyOperation(lineage(), { type: 'followParent', ref: s('x'), parents: { x: 'u', u: 'x' } })
).toThrow();
expect(plain(h.applyOperation(lineage(), { type: 'followParent', ref: s('kid'), parents }))).toEqual(
plain(h.normalizeLayout(lineage()))
);
});
});
describe('drop -> operation', () => {
const h = loadHelper();
it('maps a row dropped before/after a row, onto a header, and onto Ungrouped', () => {
expect(
h.dropOperation(
base(),
{ type: 'ref', ref: s('c') },
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'before' },
{}
)
).toEqual({
type: 'moveRef',
ref: s('c'),
groupId: 'g1',
index: 1,
parents: {},
});
expect(
h.dropOperation(
base(),
{ type: 'ref', ref: s('c') },
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'after' },
{}
).index
).toBe(2);
// Within the same container the index counts AFTER the moved row is taken out.
expect(
h.dropOperation(
base(),
{ type: 'ref', ref: s('a') },
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'after' },
{}
)
).toMatchObject({
groupId: 'g1',
index: 1,
});
// Onto a header or Ungrouped means "at the end": no index, so a replay onto
// a layout that gained rows meanwhile still lands it last.
const onHeader = h.dropOperation(base(), { type: 'ref', ref: w('web') }, { type: 'group', groupId: 'g1' }, {});
expect(onHeader).toMatchObject({ ref: w('web'), groupId: 'g1' });
expect(onHeader).not.toHaveProperty('index');
const onUngrouped = h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {});
expect(onUngrouped).toMatchObject({ groupId: null });
expect(onUngrouped).not.toHaveProperty('index');
const crowded = {
...base(),
groups: [{ ...base().groups[0], refs: [...base().groups[0].refs, s('late')] }, base().groups[1]],
};
const replayed = h.applyOperation(crowded, onHeader);
expect(replayed.groups[0].refs.at(-1)).toEqual(w('web'));
});
it('maps a group dropped on another group (or a row in it) to a reorder', () => {
expect(h.dropOperation(base(), { type: 'group', groupId: 'g2' }, { type: 'group', groupId: 'g1' }, {})).toEqual({
type: 'reorderGroup',
groupId: 'g2',
index: 0,
});
expect(
h.dropOperation(
base(),
{ type: 'group', groupId: 'g1' },
{ type: 'ref', ref: w('web'), groupId: 'g2', placement: 'before' },
{}
)
).toEqual({
type: 'reorderGroup',
groupId: 'g1',
index: 1,
});
expect(h.dropOperation(base(), { type: 'group', groupId: 'g1' }, { type: 'ungrouped' }, {})).toMatchObject({
index: 1,
});
});
it('returns null for a drop that changes nothing', () => {
expect(
h.dropOperation(
base(),
{ type: 'ref', ref: s('a') },
{ type: 'ref', ref: s('a'), groupId: 'g1', placement: 'after' },
{}
)
).toBeNull();
expect(
h.dropOperation(
base(),
{ type: 'ref', ref: s('a') },
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'before' },
{}
)
).toBeNull();
expect(h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'group', groupId: 'g1' }, {})).toBeNull();
expect(h.dropOperation(base(), { type: 'ref', ref: s('c') }, { type: 'ungrouped' }, {})).toBeNull();
expect(h.dropOperation(base(), { type: 'group', groupId: 'g1' }, { type: 'group', groupId: 'g1' }, {})).toBeNull();
// Onto its own following child: the child moves with it, so there is no slot.
const layout = { ...base(), ungrouped: [s('c'), s('child')] };
expect(
h.dropOperation(
layout,
{ type: 'ref', ref: s('c') },
{ type: 'ref', ref: s('child'), groupId: null, placement: 'after' },
{ child: 'c' }
)
).toBeNull();
});
});
describe('edit coordinator', () => {
const h = loadHelper();
/** A put() whose responses the test resolves by hand, in order. */
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 };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
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('applies at once and PUTs exactly { baseVersion, layout } with the held version', async () => {
const { put, calls } = controlledPut();
const { editor, applied } = makeEditor(put);
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Front' });
expect(applied.at(-1).layout.groups[0].name).toBe('Front');
expect(applied.at(-1).meta).toEqual({ optimistic: true });
await settle();
expect(calls).toHaveLength(1);
expect(Object.keys(calls[0].request).sort()).toEqual(['baseVersion', 'layout']);
expect(calls[0].request.baseVersion).toBe(5);
expect(calls[0].request.layout.groups[0].name).toBe('Front');
calls[0].resolve({
ok: true,
status: 200,
layout: { ...base(6), groups: [{ ...base().groups[0], name: 'Front' }, base().groups[1]] },
});
await settle();
expect(editor.hasPending()).toBe(false);
expect(editor.baseVersion()).toBe(6);
});
it('batches edits made in one turn into a single PUT', async () => {
const { put, calls } = controlledPut();
const { editor } = makeEditor(put);
editor.enqueue({ type: 'createGroup', id: 'g3', name: 'Three' });
editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g3', index: 0 });
await settle();
expect(calls).toHaveLength(1);
expect(keys(calls[0].request.layout.groups[2].refs)).toEqual(['session:c']);
});
it('keeps ONE write in flight and sends later edits on the version it returns', async () => {
const { put, calls } = controlledPut();
const { editor } = makeEditor(put);
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'First' });
await settle();
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Second' });
await settle();
await settle();
// The second edit waits: no concurrent PUT racing the first.
expect(calls).toHaveLength(1);
const confirmed = plain(calls[0].request.layout);
calls[0].resolve({ ok: true, status: 200, layout: { ...confirmed, version: 6 } });
await settle();
await settle();
expect(calls).toHaveLength(2);
expect(calls[1].request.baseVersion).toBe(6);
expect(calls[1].request.layout.groups.map((g: any) => g.name)).toEqual(['First', 'Second']);
calls[1].resolve({ ok: true, status: 200, layout: { ...plain(calls[1].request.layout), version: 7 } });
await settle();
expect(editor.hasPending()).toBe(false);
});
it('rebases a 409 onto the server layout, keeping the concurrent edit, and retries once', async () => {
const { put, calls } = controlledPut();
const { editor, applied } = makeEditor(put);
editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g1', index: 2 });
await settle();
expect(calls[0].request.baseVersion).toBe(5);
// Elsewhere, someone created a group and renamed Ops (version 9).
const server: Layout = {
...base(9),
groups: [
...base().groups.map((g) => (g.id === 'g2' ? { ...g, name: 'Ops!' } : g)),
{ id: 'gx', name: 'Theirs', refs: [] },
],
};
calls[0].resolve({ ok: false, status: 409, layout: server });
await settle();
expect(calls).toHaveLength(2);
expect(calls[1].request.baseVersion).toBe(9);
const retried = calls[1].request.layout;
expect(retried.groups.map((g: any) => g.name)).toEqual(['Core', 'Ops!', 'Theirs']);
expect(keys(retried.groups[0].refs)).toEqual(['session:a', 'session:b', 'session:c']);
calls[1].resolve({ ok: true, status: 200, layout: { ...retried, version: 10 } });
await settle();
expect(applied.at(-1).meta).toEqual({ authoritative: true });
expect(applied.at(-1).layout.version).toBe(10);
expect(editor.hasPending()).toBe(false);
});
it('drops (and reports) an edit the conflicting layout no longer supports', async () => {
const { put, calls } = controlledPut();
const reportError = vi.fn();
const { editor, applied } = makeEditor(put, { reportError });
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Mine' });
await settle();
const server: Layout = { ...base(9), groups: [base().groups[0]], ungrouped: [...base().ungrouped, w('web')] };
calls[0].resolve({ ok: false, status: 409, layout: server });
await settle();
expect(calls).toHaveLength(1);
expect(reportError).toHaveBeenCalledTimes(1);
expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1']);
expect(editor.hasPending()).toBe(false);
});
it('reports an edit queued during a save that the save conflict no longer supports', async () => {
const { put, calls } = controlledPut();
const reportError = vi.fn();
const { editor, applied } = makeEditor(put, { reportError });
editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g1', index: 2 });
await settle();
// Queued behind the write in flight, for a group someone else deletes meanwhile.
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Mine' });
await settle();
expect(calls).toHaveLength(1);
const server: Layout = { ...base(9), groups: [base().groups[0]], ungrouped: [...base().ungrouped, w('web')] };
calls[0].resolve({ ok: false, status: 409, layout: server });
await settle();
expect(calls).toHaveLength(2);
calls[1].resolve({ ok: true, status: 200, layout: { ...plain(calls[1].request.layout), version: 10 } });
await settle();
await settle();
expect(calls).toHaveLength(2);
expect(reportError).toHaveBeenCalledTimes(1);
expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1']);
expect(editor.hasPending()).toBe(false);
});
it('gives up after bounded conflicts and asks the caller to re-read', async () => {
const { put, calls } = controlledPut();
const onFailure = vi.fn();
const reportError = vi.fn();
const { editor } = makeEditor(put, { onFailure, reportError, maxAttempts: 2 });
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
await settle();
calls[0].resolve({ ok: false, status: 409, layout: base(7) });
await settle();
calls[1].resolve({ ok: false, status: 409, layout: base(8) });
await settle();
expect(calls).toHaveLength(2);
expect(onFailure).toHaveBeenCalledTimes(1);
expect(reportError).toHaveBeenCalledTimes(1);
expect(editor.getLayout().groups[0].name).toBe('Core');
});
it('re-reads and rebases on a 400, and reports a network failure', async () => {
const { put, calls } = controlledPut();
const fetchLayout = vi.fn(async () => base(11));
const onFailure = vi.fn();
const { editor } = makeEditor(put, { fetchLayout, onFailure, reportError: vi.fn() });
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
await settle();
calls[0].resolve({ ok: false, status: 400, layout: null });
await settle();
await settle();
expect(fetchLayout).toHaveBeenCalledTimes(1);
expect(calls[1].request.baseVersion).toBe(11);
calls[1].resolve({ ok: false, status: 0, layout: null });
await settle();
expect(onFailure).toHaveBeenCalledTimes(1);
});
it('reports a 400 that survives the re-read as a failed save, not as a race', async () => {
const { put, calls } = controlledPut();
const fetchLayout = vi.fn(async () => base(11));
const reportError = vi.fn();
const onFailure = vi.fn();
const { editor } = makeEditor(put, { fetchLayout, onFailure, reportError, maxAttempts: 3 });
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
await settle();
calls[0].resolve({ ok: false, status: 400, layout: null });
await settle();
await settle();
calls[1].resolve({ ok: false, status: 400, layout: null });
await settle();
expect(calls).toHaveLength(2);
expect(fetchLayout).toHaveBeenCalledTimes(1);
expect(reportError).toHaveBeenCalledWith('Could not save tab groups.');
expect(reportError).not.toHaveBeenCalledWith('Tab groups kept changing elsewhere; your edit was not saved.');
expect(onFailure).toHaveBeenCalledTimes(1);
});
it('refuses an external layout while writing, rebases pending edits onto one otherwise', async () => {
const { put, calls } = controlledPut();
const { editor, applied } = makeEditor(put);
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
await settle();
expect(editor.adoptExternal(base(20))).toBe(false);
calls[0].resolve({ ok: true, status: 200, layout: { ...plain(calls[0].request.layout), version: 6 } });
await settle();
expect(editor.adoptExternal(base(4))).toBe(false); // older than what we hold
const external = { ...base(21), groups: [...base().groups, { id: 'gx', name: 'X', refs: [] }] };
expect(editor.adoptExternal(external)).toBe(true);
expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1', 'g2', 'gx']);
});
it('restores recovered edits only when they still change something', async () => {
const { put, calls } = controlledPut();
const { editor } = makeEditor(put);
expect(editor.restore([{ type: 'renameGroup', groupId: 'g1', name: 'Core' }])).toBe(false);
expect(editor.restore([{ type: 'renameGroup', groupId: 'gone', name: 'x' }])).toBe(false);
expect(editor.restore([{ type: 'renameGroup', groupId: 'g1', name: 'Again' }])).toBe(true);
await settle();
expect(calls).toHaveLength(1);
expect(editor.pendingOperations()).toEqual([{ type: 'renameGroup', groupId: 'g1', name: 'Again' }]);
});
});
// ─── app.js wiring ─────────────────────────────────────────────────────
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.__EditCodemanApp = CodemanApp;'
);
CodemanApp = win.__EditCodemanApp;
});
const serverLayout = (version = 8): Layout => ({
version,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [
{ id: 'gx', name: '<Core & Ops>', refs: [s('s2'), w('w1')] },
{ id: 'gy', name: 'Later', refs: [] },
],
ungrouped: [s('s1'), s('s3')],
});
/** fetch stub: records PUT bodies, answers each with the next queued response. */
function installFetch(responses: Array<(body: any) => { status: number; body: unknown }> = []) {
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 next =
responses.shift() ||
((b: any) => ({
status: 200,
body: { success: true, data: { layout: { ...b.layout, version: b.baseVersion + 1 } } },
}));
const { status, body: payload } = next(body);
return { ok: status >= 200 && status < 300, status, json: async () => payload };
});
return puts;
}
function makeApp(layout: unknown = serverLayout()) {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
document.documentElement.dataset.tabRailSort = 'manual';
document.body.innerHTML = '<div id="sessionTabs" role="tablist" aria-label="Session tabs"></div>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['s1', { id: 's1', name: 'One', status: 'idle' }],
['s2', { id: 's2', name: 'Two', 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 = () => {};
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app.applySidebarFilter = () => {};
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => {};
app._stopSidebarRichClock = () => {};
app.loadAppSettingsFromStorage = () => ({});
app.openSessionOptions = vi.fn();
app.requestCloseSession = vi.fn();
app.selectSession = vi.fn();
app.showWebviewModal = vi.fn();
app.showToast = vi.fn();
app.closeAllPanels = vi.fn();
// The page renders the flat rail before the first layout read lands.
app._fullRenderSessionTabs();
if (layout) app._applyTabLayout(layout);
return app;
}
const tabs = () => document.getElementById('sessionTabs')!;
const header = (id: string) => document.querySelector<HTMLElement>(`[data-tab-group-header="${id}"]`)!;
const row = (id: string) => document.querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
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 key = (target: Element, k: string, init: Record<string, unknown> = {}) =>
target.dispatchEvent(new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
const flush = async () => {
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0));
};
beforeEach(() => {
win.localStorage.clear();
win.sessionStorage.clear();
document.body.innerHTML = '';
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
});
afterEach(() => {
document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove());
});
describe('row actions in the vertical rail', () => {
it('offers group moves (and only "new group" before any group exists), never on the strip', () => {
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']);
flat.closeTabRailActionMenu();
const app = makeApp();
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).toEqual([
'Session options',
'Move down',
'Move to "Later"',
'Move to Ungrouped',
'Move to new group',
'Close session',
]);
app.closeTabRailActionMenu();
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app._fullRenderSessionTabs();
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).toEqual(['Session options', 'Close session']);
});
it('quotes group names, so a group called "New group" or "ungrouped" reads apart from the fixed entries', () => {
installFetch();
const app = makeApp({
...serverLayout(),
groups: [
{ id: 'gn', name: 'New group', refs: [s('s2')] },
{ id: 'gu', name: 'ungrouped', refs: [] },
],
});
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).toEqual([
'Session options',
'Move to "ungrouped"',
'Move to Ungrouped',
'Move to new group',
'Close session',
]);
app.closeTabRailActionMenu();
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
expect(menuLabels()).toContain('Move to "New group"');
expect(menuLabels()).toContain('Move to new group');
});
it('moves a row into another group with one PUT carrying the held version', async () => {
const puts = installFetch();
const app = makeApp();
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
clickMenu('Move to "Later"');
// Optimistic: the rail already shows it there.
expect(row('s1').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gy');
await flush();
expect(puts).toHaveLength(1);
expect(puts[0].baseVersion).toBe(8);
expect(puts[0].layout.groups[1].refs).toEqual([s('s1')]);
expect(keys(puts[0].layout.ungrouped)).toEqual(['session:s3']);
expect(typeof puts[0].layout.updatedAt).toBe('string');
expect(app.tabLayout.version).toBe(9);
// Focus lands on the moved row, so a keyboard user stays in the rail.
expect(document.activeElement).toBe(row('s1'));
});
it('creates the first group from a flat rail and goes straight into renaming it', async () => {
const puts = installFetch();
const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
expect(tabs().getAttribute('role')).toBe('tablist');
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s3') }, 's3');
clickMenu('Move to new group');
// The rail is now grouped, with an editor in the new header.
expect(tabs().getAttribute('role')).toBe('tree');
const input = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
expect(input).not.toBeNull();
expect(document.activeElement).toBe(input);
expect(input.value).toBe('New group');
input.value = ' Build <x> ';
key(input, 'Enter');
await flush();
// Create, move and the rename made in the same turn share ONE write.
expect(puts).toHaveLength(1);
expect(puts[0].baseVersion).toBe(8);
expect(puts[0].layout.groups).toHaveLength(1);
expect(puts[0].layout.groups[0].name).toBe('Build <x>');
expect(puts[0].layout.groups[0].refs).toEqual([s('s3')]);
// The name is text, never markup.
const name = header(puts[0].layout.groups[0].id).querySelector('.tab-layout-group-name')!;
expect(name.textContent).toBe('Build <x>');
expect(name.children).toHaveLength(0);
// A later rename goes out on the version that write returned.
app.startTabGroupRename(puts[0].layout.groups[0].id);
const again = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
again.value = 'Build';
key(again, 'Enter');
await flush();
expect(puts).toHaveLength(2);
expect(puts[1].baseVersion).toBe(9);
expect(puts[1].layout.groups[0].name).toBe('Build');
expect(app._inlineRenameActive).toBe(false);
});
});
describe('web tab rows', () => {
it('Shift+F10 on a web tab offers its settings and the same group moves', async () => {
const puts = installFetch();
const app = makeApp();
const web = document.querySelector<HTMLElement>('.session-tab[data-webview-id="w1"]')!;
web.focus();
key(web, 'F10', { shiftKey: true });
expect(menuLabels()).toEqual([
'Web tab settings',
'Move up',
'Move to "Later"',
'Move to Ungrouped',
'Move to new group',
]);
clickMenu('Move to Ungrouped');
await flush();
expect(keys(puts[0].layout.ungrouped)).toEqual(['session:s1', 'session:s3', 'webview:w1']);
expect(document.activeElement).toBe(document.querySelector('.session-tab[data-webview-id="w1"]'));
expect(app.showWebviewModal).not.toHaveBeenCalled();
});
it('moves down past the sessions that follow the moved one', async () => {
const puts = installFetch();
const app = makeApp({
...serverLayout(),
groups: [{ id: 'gx', name: 'G', refs: [s('s1'), s('s2'), s('s3')] }],
ungrouped: [],
});
app.sessions.get('s2').parentSessionId = 's1';
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
clickMenu('Move down');
await flush();
expect(keys(puts[0].layout.groups[0].refs)).toEqual(['session:s3', 'session:s1', 'session:s2']);
});
});
describe('group cap', () => {
it('stops offering a new group once the server cap is reached', () => {
installFetch();
const groups = Array.from({ length: 32 }, (_, i) => ({
id: `g${i}`,
name: `G${i}`,
refs: i === 0 ? [s('s1'), s('s2'), s('s3')] : [],
}));
const app = makeApp({ ...serverLayout(), groups, ungrouped: [] });
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
expect(menuLabels()).not.toContain('Move to new group');
expect(menuLabels()).toContain('Move to "G1"');
app.closeTabRailActionMenu();
header('g0').focus();
key(header('g0'), 'F10', { shiftKey: true });
// "New session" adds a tab, not a group, so it stays offered at the cap.
expect(menuLabels()).toEqual(['New session', 'Rename group', 'Move group down', 'Delete group']);
app.closeTabGroupMenu();
});
});
describe('group menu', () => {
it('opens from Shift+F10 / ContextMenu on a header and runs each group operation', async () => {
const puts = installFetch();
const app = makeApp();
win.confirm = vi.fn(() => true);
header('gx').focus();
key(header('gx'), 'F10', { shiftKey: true });
expect(menuLabels()).toEqual(['New session', 'Rename group', 'New group', 'Move group down', 'Delete group']);
expect(document.activeElement?.textContent).toBe('New session');
clickMenu('Move group down');
await flush();
expect(puts.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']);
expect(document.activeElement).toBe(header('gx'));
key(header('gx'), 'ContextMenu');
expect(menuLabels()).toEqual(['New session', 'Rename group', 'New group', 'Move group up', 'Delete group']);
clickMenu('Delete group');
expect(win.confirm).toHaveBeenCalledWith('Delete group "<Core & Ops>"? Its tabs move to Ungrouped.');
await flush();
const last = puts.at(-1).layout;
expect(last.groups.map((g: any) => g.id)).toEqual(['gy']);
expect(keys(last.ungrouped)).toEqual(['session:s1', 'session:s3', 'session:s2', 'webview:w1']);
});
it('cancelling "Delete group" returns focus to the header, with no write', async () => {
const puts = installFetch();
const app = makeApp();
win.confirm = vi.fn(() => false);
header('gy').focus();
key(header('gy'), 'F10', { shiftKey: true });
clickMenu('Delete group');
expect(win.confirm).toHaveBeenCalledTimes(1);
await flush();
expect(puts).toHaveLength(0);
expect(document.activeElement).toBe(header('gy'));
expect(app.tabLayout.groups).toHaveLength(2);
});
it('closing the row menu (as session:deleted does) leaves an open group menu and its listeners alone', () => {
installFetch();
const app = makeApp();
header('gx').focus();
key(header('gx'), 'F10', { shiftKey: true });
const menu = document.querySelector('.tab-layout-group-action-menu');
expect(menu).not.toBeNull();
app.closeTabRailActionMenu();
expect(menu!.isConnected).toBe(true);
expect(app._tabGroupMenu).toBe(menu);
app.closeTabGroupMenu();
expect(menu!.isConnected).toBe(false);
expect(app._tabGroupMenuKeydown).toBeNull();
});
it('renames from F2 and cancels on Escape without a write', async () => {
const puts = installFetch();
const app = makeApp();
header('gy').focus();
key(header('gy'), 'F2');
const input = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
input.value = 'Changed';
key(input, 'Escape');
await flush();
expect(puts).toHaveLength(0);
expect(header('gy').querySelector('.tab-layout-group-name')!.textContent).toBe('Later');
expect(document.activeElement).toBe(header('gy'));
expect(app._inlineRenameActive).toBe(false);
});
it('ignores IME composition keys and blocks re-renders while editing', async () => {
const puts = installFetch();
const app = makeApp();
app.startTabGroupRename('gy');
const input = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
input.value = '组';
key(input, 'Enter', { isComposing: true });
expect(document.querySelector('.tab-layout-group-rename-input')).toBe(input);
app._fullRenderSessionTabs(); // a background render must not destroy the editor
expect(input.isConnected).toBe(true);
input.blur();
await flush();
expect(puts.at(-1).layout.groups[1].name).toBe('组');
});
it('a session rename takes the editor over and a stale group editor cannot release the guard', () => {
installFetch();
const app = makeApp();
app.startTabGroupRename('gy');
const handle = app._activeRename;
expect(handle.groupId).toBe('gy');
const other = { cancel: vi.fn() };
app._activeRename = other; // someone newer owns the guard
handle.cancel();
expect(app._inlineRenameActive).toBe(true);
});
describe('dismissal', () => {
const open = (app: Record<string, any>) => {
header('gx').focus();
key(header('gx'), 'F10', { shiftKey: true });
expect(document.querySelector('.tab-layout-group-action-menu')).not.toBeNull();
};
const isOpen = () => document.querySelector('.tab-layout-group-action-menu') !== null;
it('Escape closes only the menu and returns focus to its header', () => {
installFetch();
const app = makeApp();
open(app);
const escape = new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
document.activeElement!.dispatchEvent(escape);
expect(isOpen()).toBe(false);
expect(document.activeElement).toBe(header('gx'));
expect(app._tabGroupMenuKeydown).toBeNull();
});
it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => {
installFetch();
const app = makeApp();
open(app);
document.body.dispatchEvent(new win.Event('pointerdown', { bubbles: true }));
expect(isOpen()).toBe(false);
open(app);
// A pointer INSIDE the menu does not close it.
document
.querySelector('.tab-layout-group-action-menu button')!
.dispatchEvent(new win.Event('pointerdown', { bubbles: true }));
expect(isOpen()).toBe(true);
key(document.activeElement!, 'Tab');
expect(isOpen()).toBe(false);
expect(document.activeElement).toBe(header('gx'));
open(app);
const buttons = document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button');
buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: buttons[1] }));
expect(isOpen()).toBe(true);
buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: document.body }));
expect(isOpen()).toBe(false);
open(app);
win.dispatchEvent(new win.Event('resize'));
expect(isOpen()).toBe(false);
open(app);
app.openTabGroupMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: header('gx') }, 'gx');
expect(isOpen()).toBe(false);
open(app);
app._fullRenderSessionTabs();
expect(isOpen()).toBe(false);
// No listener is left behind by any of the paths.
expect(app._tabGroupMenuOutside).toBeNull();
});
});
});
describe('new session in a group', () => {
it('expands a collapsed group and launches ONE session targeted at it', async () => {
installFetch();
const app = makeApp();
app.toggleTabGroupCollapsed('gx', true);
app.run = vi.fn(async () => {});
header('gx').focus();
key(header('gx'), 'F10', { shiftKey: true });
clickMenu('New session');
await flush();
expect(app.run).toHaveBeenCalledTimes(1);
expect(app.run).toHaveBeenCalledWith({ count: 1, tabGroupId: 'gx' });
expect(app.collapsedTabGroupIds.has('gx')).toBe(false);
expect(JSON.parse(win.localStorage.getItem('codeman:tab-groups-collapsed'))).toEqual([]);
expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull();
});
it('says so, and starts nothing, while another launch is in flight', async () => {
installFetch();
const app = makeApp();
app.toggleTabGroupCollapsed('gx', true);
app.run = vi.fn(async () => {});
app._runInFlight = true;
await app.createSessionInTabGroup('gx');
expect(app.run).not.toHaveBeenCalled();
expect(app.collapsedTabGroupIds.has('gx')).toBe(true);
expect(app.showToast).toHaveBeenCalledWith('A session is already starting.', 'info');
// A group that no longer exists starts nothing either.
app._runInFlight = false;
expect(await app.createSessionInTabGroup('deleted')).toBe(false);
expect(app.run).not.toHaveBeenCalled();
});
});
describe('child session placement in the row menu', () => {
const lineageLayout = (): Layout => ({
version: 8,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [
{ id: 'gx', name: 'Core', refs: [s('s1'), s('s2')] },
{ id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 's3', placement: 'manual' }] },
],
ungrouped: [],
});
const openMenu = (app: Record<string, any>, id: string) =>
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row(id) }, id);
/** The synced "Spawned tabs follow their parent" setting, ON. */
const following = (app: Record<string, any>) => {
app.loadAppSettingsFromStorage = () => ({ spawnedTabsFollowParent: true });
return app;
};
const note = () => document.querySelector<HTMLElement>('.tab-rail-action-menu .tab-rail-action-menu-note');
it('shows where a child sits and offers Follow parent again only to a hand-placed child', async () => {
const puts = installFetch();
const app = following(makeApp(lineageLayout()));
app.sessions.get('s2').parentSessionId = 's1';
app.sessions.get('s3').parentSessionId = 's1';
openMenu(app, 's2');
expect(note()!.textContent).toBe('Follows One');
expect(menuLabels()).not.toContain('Follows One');
expect(menuLabels()).not.toContain('Follow parent again');
// Information, not an action: never focusable, never a menu item, and the
// menu opens on its first real action.
expect(note()!.tagName).not.toBe('BUTTON');
expect(note()!.getAttribute('role')).toBe('none');
expect(note()!.hasAttribute('tabindex')).toBe(false);
expect(document.activeElement?.textContent).toBe('Session options');
expect(document.querySelector('.tab-rail-action-menu')!.getAttribute('aria-describedby')).toBe(note()!.id);
note()!.click();
expect(document.querySelector('.tab-rail-action-menu')).not.toBeNull();
app.closeTabRailActionMenu();
openMenu(app, 's3');
expect(note()!.textContent).toBe('Placed by hand (parent: One)');
clickMenu('Follow parent again');
// Optimistic: the child is back under its parent at once.
expect(row('s3').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx');
await flush();
expect(puts).toHaveLength(1);
expect(puts[0].layout.groups[0].refs).toEqual([s('s1'), s('s2'), s('s3')]);
expect(puts[0].layout.groups[1].refs).toEqual([]);
});
it('a child whose parent is gone says so and cannot follow', () => {
installFetch();
const app = makeApp({
...lineageLayout(),
groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 's2', placement: 'manual' }] }],
ungrouped: [s('s3')],
});
following(app);
app.sessions.delete('s1');
app.sessions.get('s2').parentSessionId = 's1';
openMenu(app, 's2');
expect(note()!.textContent).toBe('Parent closed; placed on its own');
expect(menuLabels()).not.toContain('Follow parent again');
});
it('with the follow setting off (the default) shows no follow note and no Follow parent again', () => {
installFetch();
const app = makeApp(lineageLayout());
app.sessions.get('s2').parentSessionId = 's1';
app.sessions.get('s3').parentSessionId = 's1';
for (const id of ['s2', 's3']) {
openMenu(app, id);
expect(note()).toBeNull();
expect(menuLabels()).not.toContain('Follow parent again');
expect(menuLabels()[0]).toBe('Session options');
app.closeTabRailActionMenu();
}
});
it('a hand move makes a following child manual, so it stays when its parent moves', async () => {
const puts = installFetch();
const app = following(makeApp(lineageLayout()));
app.sessions.get('s2').parentSessionId = 's1';
openMenu(app, 's2');
clickMenu('Move to "Later"');
await flush();
expect(puts[0].layout.groups[1].refs).toEqual([
{ kind: 'session', id: 's3', placement: 'manual' },
{ kind: 'session', id: 's2', placement: 'manual' },
]);
openMenu(app, 's1');
clickMenu('Move to Ungrouped');
await flush();
expect(puts[1].layout.ungrouped).toEqual([s('s1')]);
expect(keys(puts[1].layout.groups[1].refs)).toEqual(['session:s3', 'session:s2']);
});
it('adds nothing to the menu for a session without a parent, or outside a grouped rail', () => {
installFetch();
const app = following(makeApp(lineageLayout()));
openMenu(app, 's1');
expect(menuLabels()).toEqual([
'Session options',
'Move down',
'Move to "Later"',
'Move to Ungrouped',
'Move to new group',
'Close session',
]);
});
});
describe('the server places created and closed sessions; the browser never echoes them', () => {
const orderPuts = () =>
(win.fetch as any).mock.calls.filter(([url]: [string]) => url === '/api/session-order').length;
const stub = (app: Record<string, any>) => {
app._debounceTimers = {};
app.markSessionTabEntering = () => {};
app.markTerminalEntering = () => {};
app.markConnectionLineEntering = () => {};
app.renderSessionTabs = () => app._fullRenderSessionTabs();
app.updateCost = () => {};
app.startSystemStatsPolling = () => {};
};
/** Create then delete `kid`, with the server's order broadcast landing first. */
const createAndClose = async (app: Record<string, any>, serverOrder: string[]) => {
app._onSessionOrderChanged({ order: serverOrder });
app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' });
const created = [...app.sessionOrder];
const drawn = [...tabs().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((el) => el.dataset.id);
await new Promise((resolve) => setTimeout(resolve, 450));
const putsAfterCreate = orderPuts();
// The order bookkeeping runs first in _cleanupSessionData; the buffer and
// timer maps it clears afterwards are not part of this harness.
try {
app._cleanupSessionData('kid');
} catch {}
const closed = [...app.sessionOrder];
await new Promise((resolve) => setTimeout(resolve, 450));
return { created, drawn, closed, putsAfterCreate, putsAfterClose: orderPuts() };
};
it('with groups: a spawned child is drawn after its parent and NOT echoed back as a hand order', async () => {
installFetch();
const app = makeApp();
stub(app);
const result = await createAndClose(app, ['s1', 's2', 'kid', 's3']);
expect(result).toMatchObject({
created: ['s1', 's2', 'kid', 's3'],
// Drawn inside the parent's group, right after it (the rail draws groups first).
drawn: ['s2', 'kid', 's1', 's3'],
closed: ['s1', 's2', 's3'],
putsAfterCreate: 0,
putsAfterClose: 0,
});
});
it('with no groups (the flat rail): the server broadcast decides the order, and nothing is echoed', async () => {
installFetch();
const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
stub(app);
const result = await createAndClose(app, ['s1', 's2', 'kid', 's3']);
expect(result).toMatchObject({
created: ['s1', 's2', 'kid', 's3'],
closed: ['s1', 's2', 's3'],
putsAfterCreate: 0,
putsAfterClose: 0,
});
// The header strip and phones read the same sessionOrder the flat rail drew.
expect(result.drawn).toEqual(['s1', 's2', 'kid', 's3']);
});
it('with no layout read at all (the header strip): a session arriving before its order is appended, not saved', async () => {
installFetch();
const app = makeApp(null);
stub(app);
app._onSessionCreated({ id: 'late', name: 'Late', status: 'idle' });
expect(app.sessionOrder).toEqual(['s1', 's2', 's3', 'late']);
await new Promise((resolve) => setTimeout(resolve, 450));
expect(orderPuts()).toBe(0);
// The server's broadcast then wins.
app._onSessionOrderChanged({ order: ['late', 's1', 's2', 's3'] });
expect(app.sessionOrder).toEqual(['late', 's1', 's2', 's3']);
});
});
describe('a session arriving inside a collapsed group', () => {
it('briefly marks the group header instead of arriving silently', async () => {
installFetch();
const app = makeApp();
app.toggleTabGroupCollapsed('gx', true);
// Collapsing alone hides rows that were already there: no mark.
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(false);
app.sessions.set('kid', { id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' });
app.sessionOrder.push('kid');
app._fullRenderSessionTabs();
expect(row('kid')).toBeNull();
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(true);
expect(header('gy').classList.contains('tab-layout-group-arrived')).toBe(false);
// It survives a re-render while it lasts, then goes.
app._fullRenderSessionTabs();
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 2100));
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(false);
app._fullRenderSessionTabs();
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(false);
});
});
describe('saved web tabs in groups (openness is per device)', () => {
const webLayout = (): Layout => ({
version: 8,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [{ id: 'gx', name: 'Dash', refs: [s('s1'), w('w1'), s('s2')] }],
ungrouped: [s('s3'), w('w2')],
});
const webRows = () =>
[...document.querySelectorAll<HTMLElement>('.session-tab[data-webview-id]')].map((el) => [
el.closest('.tab-layout-group')!.getAttribute('data-tab-group-id'),
el.dataset.webviewId,
el.querySelector('.tab-number')?.textContent ?? null,
]);
it('shows a grouped web tab only while it is open here, and reopens it where the layout keeps it', () => {
installFetch();
const app = makeApp(null);
app.webviews = new Map([
['w1', { id: 'w1', name: 'One', url: 'https://one.test' }],
['w2', { id: 'w2', name: 'Two', url: 'https://two.test' }],
]);
app.webviewOrder = ['w1'];
app._applyTabLayout(webLayout());
// w2 is saved and placed, but not open on this device: no row, no number.
expect(webRows()).toEqual([['gx', 'w1', '4']]);
expect(header('gx').querySelector('.tab-layout-group-count')!.textContent).toBe('3');
app.webviewOrder = [];
app._fullRenderSessionTabs();
expect(webRows()).toEqual([]);
// The group survives with its sessions; the layout still holds the web tab.
expect(header('gx').querySelector('.tab-layout-group-count')!.textContent).toBe('2');
expect(keys(app.tabLayout.groups[0].refs)).toEqual(['session:s1', 'webview:w1', 'session:s2']);
app.webviewOrder = ['w2', 'w1'];
app._fullRenderSessionTabs();
const order = [...tabs().querySelectorAll<HTMLElement>('.session-tab')].map(
(el) => el.dataset.webviewId || el.dataset.id
);
expect(order).toEqual(['s1', 'w1', 's2', 's3', 'w2']);
});
});
describe('server echoes and reloads', () => {
it('defers an SSE-triggered read while its own write is in flight, then re-reads once', async () => {
let releasePut: (() => void) | null = null;
const gets: number[] = [];
const puts: any[] = [];
win.fetch = vi.fn(async (_url: string, init: any) => {
if (init?.method === 'PUT') {
const body = JSON.parse(init.body);
puts.push(body);
await new Promise<void>((resolve) => (releasePut = resolve));
return {
ok: true,
status: 200,
json: async () => ({ success: true, data: { layout: { ...body.layout, version: 9 } } }),
};
}
gets.push(1);
return {
ok: true,
status: 200,
json: async () => ({ success: true, data: { layout: { ...serverLayout(9), groups: puts[0].layout.groups } } }),
};
});
const app = makeApp();
app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Mine' });
await flush();
expect(puts).toHaveLength(1);
app._onTabLayoutChanged({ owner: '@single', version: 9 });
await flush();
expect(gets).toHaveLength(0);
releasePut!();
await flush();
expect(app.tabLayout.groups[1].name).toBe('Mine');
expect(gets).toHaveLength(1);
});
it('a failed read during a write keeps the edit and its editor, and the 409 still rebases', async () => {
const pending: Array<{ body: any; resolve: (r: any) => void }> = [];
const gets: number[] = [];
win.fetch = vi.fn((_url: string, init: any) => {
if (init?.method === 'PUT') {
const body = JSON.parse(init.body);
return new Promise((resolve) => pending.push({ body, resolve }));
}
gets.push(1);
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ success: true, data: { layout: app.tabLayout } }),
});
});
const app = makeApp();
app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Mine' });
await flush();
expect(pending).toHaveLength(1);
const editor = app._tabLayoutEditor;
// The layout read failed (the load coordinator's fallback) while the PUT is out.
app._applyTabLayout(null);
expect(app._tabLayoutEditor).toBe(editor);
expect(app.tabLayout.groups[1].name).toBe('Mine');
expect(tabs().getAttribute('role')).toBe('tree');
// Someone else wrote first: the 409 is rebased and retried, not lost.
const theirs = { ...serverLayout(9), groups: [...serverLayout().groups, { id: 'gz', name: 'Z', refs: [] }] };
pending[0].resolve({ ok: false, status: 409, json: async () => ({ success: false, data: { layout: theirs } }) });
await flush();
expect(pending).toHaveLength(2);
expect(pending[1].body.baseVersion).toBe(9);
expect(pending[1].body.layout.groups.map((g: any) => g.name)).toEqual(['<Core & Ops>', 'Mine', 'Z']);
pending[1].resolve({
ok: true,
status: 200,
json: async () => ({ success: true, data: { layout: { ...pending[1].body.layout, version: 10 } } }),
});
await flush();
expect(app.tabLayout.version).toBe(10);
expect(app.showToast).not.toHaveBeenCalled();
// The read the failure deferred runs once the write settles.
expect(gets).toHaveLength(1);
});
it("replays only its own owner's recent copy of unsaved edits", async () => {
const copy = (extra: Record<string, unknown>) =>
win.sessionStorage.setItem(
'codeman:tab-layout-pending',
JSON.stringify({
owner: '@single',
baseVersion: 8,
savedAt: Date.now(),
operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Replayed' }],
...extra,
})
);
const replays = async (extra: Record<string, unknown>) => {
copy(extra);
const puts = installFetch();
makeApp();
await flush();
expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).toBeNull();
return puts.length;
};
expect(await replays({})).toBe(1);
expect(await replays({ owner: 'alice' })).toBe(0);
expect(await replays({ savedAt: Date.now() - 5 * 60_000 })).toBe(0);
expect(await replays({ savedAt: undefined })).toBe(0);
expect(await replays({ baseVersion: 30 })).toBe(0);
// Multi-user: the copy is keyed by the user name.
win.__codemanUser = { username: 'alice', role: 'user', multiUser: true };
expect(await replays({ owner: 'alice' })).toBe(1);
expect(await replays({ owner: '@single' })).toBe(0);
// Identity not known yet: hold the copy until /api/me answers.
win.__codemanUser = undefined;
copy({});
const late = installFetch();
makeApp();
await flush();
expect(late).toHaveLength(0);
expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).not.toBeNull();
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
document.dispatchEvent(new win.CustomEvent('codeman:me'));
await flush();
expect(late).toHaveLength(1);
expect(late[0].layout.groups[1].name).toBe('Replayed');
});
it('says so when a read has to drop edits it could not rebase', () => {
installFetch();
const app = makeApp();
app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Unsent' });
// Not yet flushed (no write in flight), and the rebase refuses the read.
app._tabLayoutEditor.adoptExternal = () => false;
app._applyTabLayout(serverLayout(9));
expect(app.showToast).toHaveBeenCalledWith('Your tab group edit was not saved.', 'error');
expect(app._tabLayoutEditor).toBeNull();
});
it('keeps unsaved edits across a reload: keepalive PUT now, rebased replay after', async () => {
const puts = installFetch();
const app = makeApp();
app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Unsaved' });
// Page goes away before the flush ran (dispose stands in for the unload).
app._persistPendingTabLayoutEdits();
app._tabLayoutEditor.dispose();
expect(win.fetch).toHaveBeenCalledWith(
'/api/tab-layout',
expect.objectContaining({ method: 'PUT', keepalive: true })
);
expect(puts.at(-1).baseVersion).toBe(8);
expect(puts.at(-1).layout.groups[1].name).toBe('Unsaved');
const stored = JSON.parse(win.sessionStorage.getItem('codeman:tab-layout-pending'));
expect(stored.operations).toEqual([{ type: 'renameGroup', groupId: 'gy', name: 'Unsaved' }]);
expect(stored.owner).toBe('@single');
expect(stored.baseVersion).toBe(8);
expect(Math.abs(Date.now() - stored.savedAt)).toBeLessThan(5000);
// Next page: the keepalive lost a race; the layout moved on to version 12.
const next = installFetch();
const reloaded = makeApp({
...serverLayout(12),
groups: [...serverLayout().groups, { id: 'gz', name: 'Z', refs: [] }],
});
expect(reloaded.tabLayout.groups[1].name).toBe('Unsaved');
await flush();
expect(next).toHaveLength(1);
expect(next[0].baseVersion).toBe(12);
expect(next[0].layout.groups.map((g: any) => g.name)).toEqual(['<Core & Ops>', 'Unsaved', 'Z']);
expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).toBeNull();
// And when the keepalive DID land, nothing is re-sent.
win.sessionStorage.setItem(
'codeman:tab-layout-pending',
JSON.stringify({
owner: '@single',
baseVersion: 8,
savedAt: Date.now(),
operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Later' }],
})
);
const none = installFetch();
makeApp();
await flush();
expect(none).toHaveLength(0);
});
it('keeps the group menu glyph visible where there is no hover (touch tablets)', () => {
const css = read('styles.css');
const block = css.match(
/@media \(hover: none\) \{\s*html\[data-tab-orientation='vertical'\] \.tab-rail \.tab-layout-group-menu \{([^}]*)\}/
);
expect(block?.[1]).toMatch(/opacity:\s*1/);
});
it('leaves the flat rail byte-identical when the layout has no groups, and never edits off the rail', () => {
installFetch();
const noLayout = makeApp(null);
noLayout._fullRenderSessionTabs();
const flat = tabs().innerHTML;
makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
expect(tabs().innerHTML).toBe(flat);
expect(tabs().getAttribute('role')).toBe('tablist');
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
const strip = makeApp();
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
expect(strip.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'x' })).toBe(false);
expect(win.fetch).not.toHaveBeenCalled();
});
});