Files
Codeman/test/tab-new-session.test.ts
T
Codeman maintainer 04aa44b69c fix: apply the review items for #712 at merge
- session-ui.js _reloadCasesAfterRun: reload onto the toolbar's own case after every launch, not only one that names its case; comment updated
- test/tab-new-session.test.ts: a worktree tab's "+" Claude launch, the toolbar Run and a group's "New session" each keep the toolbar's case after a "+" moved lastUsedCase (fail without the fix); the reload setup is shared with the existing lastUsedCase test
- docs/architecture-invariants.md:349: the _reloadCasesAfterRun sentence covers every launch
- app.js _openTabLayoutMenu focusout: focus landing on the trigger is not a departure, so pressing the "+" again closes its picker in a real browser
- tab-rail-resize.js openTabRailActionMenu: close the group menu too, so the rail's ... pressed again does not leave the picker beside the row menu
- tab-rail-resize.js:329-331: the "New session here…" comment no longer says Enter on the "+" does nothing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:30:07 +01:00

840 lines
35 KiB
TypeScript

/**
* @fileoverview A session tab's "+" (`showTabNewButton`, per device, off by
* default) and the row menu's "New session here…": the picker of CLIs the
* tab's case can start, and the one-off launch beside that tab.
*
* Driven through the shipping CodemanApp inside JSDOM (app.js, tab-rail-resize.js,
* mobile-overview.js and session-ui.js loaded as the page loads them). Pins:
* - each entry is a Run menu entry (the CLI's logo, then its name), agents in
* catalog order and the shell last, the Run button's mode preselected;
* - the tab's case is found by what the session runs on: a Docker session's
* container, a remote session's host and path, a local session's folder among
* LOCAL cases only (the case records are shaped as GET /api/cases sends them:
* display paths for remote and Docker cases, the raw values nested);
* - which CLIs are offered follows the Run menu's own rule for that case
* (_runModeAvailableForCase): the host's CLIs for a local case, the
* container's for a Docker case (live probe, then the stored list, nothing
* gated for an owned container), only the shell for an unreadable one; the
* picker starts that probe when nothing is cached for a Docker case;
* - a pick runs ONE launch in the tab's case or managed worktree with the tab's
* named group, and an ungrouped tab sends no group (the server's case default
* group then applies, pinned end to end in case-default-group-routes.test.ts);
* - the Run button's mode and the toolbar case are left as they were, also when
* the mode is changed while the launch is pending and when the server has
* moved lastUsedCase to the tab's case, and a later launch that names no case
* (a worktree tab's "+", the toolbar Run, a group's "New session") keeps the
* toolbar's case too;
* - a failed launch reports the server's own error;
* - Enter and Space on the "+" open the picker (the header strip's only
* keyboard path) and stop there; every other key passes on to the strip; a
* held key's repeat cannot also pick the entry the picker focuses;
* - the row menu offers the picker on session rows only while the setting is on;
* - the picker closes on a re-render and gives focus back to the tab on Escape;
* - the setting stays per device and OFF until switched on, and every new
* string has its zh-CN entry.
*
* Port: none (stubbed fetch).
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const CATALOG = [
{ id: 'claude', kind: 'agent', label: 'Claude Code', enabled: true },
{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true },
{ id: 'codex', kind: 'agent', label: 'Codex', enabled: true },
{ id: 'gemini', kind: 'agent', label: 'Gemini CLI', enabled: true },
{ id: 'pi', kind: 'agent', label: 'Pi', enabled: false },
];
/** What this host has installed: no gemini. */
const HOST = new Set(['claude', 'codex']);
/** As GET /api/cases sends them: remote and Docker cases carry a DISPLAY path, the raw values nested. */
const CASES = [
{ name: 'toolbar-case', path: '/tmp/toolbar-case', location: 'local' },
// Same container name and same remote path on ANOTHER host, listed first: only the host tells them apart.
{
name: 'box-h2',
path: 'box:/srv/box',
location: 'docker',
docker: { hostId: 'h2', container: 'box', path: '/srv/box', containerWorkdir: '/srv/box' },
},
{
name: 'rem2',
path: 'dev@host2:/home/dev/work',
location: 'remote',
remote: { hostId: 'r2', host: 'host2', username: 'dev', path: '/home/dev/work' },
},
// Inside proj and listed before it: the deepest enclosing case wins, whatever the order.
{ name: 'nested', path: '/tmp/proj/nested', location: 'local' },
{ name: 'proj', path: '/tmp/proj', location: 'local' },
{
name: 'box',
path: 'box:/srv/box',
location: 'docker',
docker: {
hostId: 'h1',
container: 'box',
path: '/srv/box',
containerWorkdir: '/srv/box',
owned: false,
availableModes: ['gemini', 'shell'],
},
},
{
name: 'own',
path: 'own:/srv/own',
location: 'docker',
docker: { hostId: 'h1', container: 'own', path: '/srv/own', containerWorkdir: '/srv/own' },
},
// A local case whose folder is the Docker case's workspace: only a LOCAL session belongs to it.
{ name: 'clash', path: '/srv/box', location: 'linked-local' },
{
name: 'rem',
path: 'dev@host1:/home/dev/work',
location: 'remote',
remote: { hostId: 'r1', host: 'host1', username: 'dev', path: '/home/dev/work' },
},
];
const DOCKER_BOX = { hostId: 'h1', containerName: 'box', hostWorkspacePath: '/srv/box', containerWorkdir: '/srv/box' };
const DOCKER_OWN = { hostId: 'h1', containerName: 'own', hostWorkspacePath: '/srv/own', containerWorkdir: '/srv/own' };
const REMOTE_R1 = { hostId: 'r1', host: 'host1', username: 'dev', remotePath: '/home/dev/work/api' };
const SESSIONS: Array<[string, string, Record<string, unknown>?]> = [
['a', '/tmp/proj/sub'],
['b', '/tmp/proj'],
['c', '/srv/box', { docker: DOCKER_BOX }],
['d', '/srv/own', { docker: DOCKER_OWN }],
['e', '/home/u/scratch'],
['f', '/tmp/proj/.claude/worktrees/feat'],
['g', '/srv/box'],
['r', '/home/dev/work/api', { remote: REMOTE_R1 }],
['n', '/tmp/proj/nested/src'],
];
const LAYOUT = {
version: 4,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 'b' }] }],
ungrouped: ['a', 'c', 'd', 'e', 'f', 'g', 'r', 'n'].map((id) => ({ kind: 'session', id })),
};
let CodemanApp: { prototype: Record<string, any> };
/** The test's app, so afterEach can close its menus: their document listeners would outlive the DOM. */
let current: Record<string, any> | null = null;
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' +
[
'constants.js',
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
'api-client.js',
'webview-tabs.js',
'mobile-overview.js',
'session-ui.js',
]
.map(read)
.join('\n;\n') +
'\n;window.__TabNewCodemanApp = CodemanApp;'
);
CodemanApp = win.__TabNewCodemanApp;
});
function makeApp({ orientation = 'horizontal' } = {}) {
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 =
'<div id="sessionTabs" role="tablist" aria-label="Session tabs"></div>' +
'<select id="quickStartCase"><option value="toolbar-case" selected>toolbar-case</option></select>' +
'<input id="tabCount" value="3"><button id="runBtn"></button>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map(
SESSIONS.map(([id, workingDir, extra]) => [id, { id, name: id, status: 'idle', workingDir, ...extra }])
);
app.sessionOrder = SESSIONS.map(([id]) => id);
app.webviews = new Map();
app.webviewOrder = [];
app.activeSessionId = 'a';
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(async () => {});
app.showToast = vi.fn();
app.closeAllPanels = vi.fn();
// What the real run() reads beyond the DOM (the same stubs as tab-group-launch.test.ts).
app._runMinLockMs = 0;
app._runMode = 'codex';
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 };
app.cases = CASES;
app.isCliAvailable = (id: string) => HOST.has(id);
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.getEffortSetting = () => undefined;
app.isRalphTrackerEnabledByDefault = () => false;
app.getTerminalDimensions = () => null;
app.loadQuickStartCases = () => {};
app._fullRenderSessionTabs();
app._applyTabLayout(LAYOUT);
current = app;
return app;
}
const tab = (id: string) => document.querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
const plus = (id: string) => tab(id).querySelector<HTMLElement>('.tab-new')!;
const picker = () => document.querySelector<HTMLElement>('[role="menu"][aria-label="New session here"]');
/** Each entry as [logo slot classes, visible text, button classes]. */
const entries = () =>
[...(picker()?.querySelectorAll('button') || [])].map((button) => [
button.querySelector('.run-mode-dot')?.className,
button.textContent,
button.className,
]);
const openFrom = (app: Record<string, any>, id: string) =>
app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus(id) }, id);
const pick = (label: string) =>
[...picker()!.querySelectorAll<HTMLElement>('button')].find((button) => button.textContent === label)!.click();
const rowMenuLabels = () =>
[...document.querySelectorAll('.tab-rail-action-menu:not(.tab-layout-group-action-menu) button')].map(
(button) => button.textContent
);
const flush = async () => {
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0));
};
beforeEach(() => {
win.localStorage.clear();
document.body.innerHTML = '';
document.documentElement.className = '';
win.__codemanCliCatalog = CATALOG;
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
});
afterEach(() => {
current?.closeTabGroupMenu();
current?.closeTabRailActionMenu();
current = null;
});
describe('the tab "+" picker', () => {
it('lists the CLIs the case can start as logo + name, agents in catalog order, the shell last', () => {
const app = makeApp();
// The markup wires the "+" to this picker for its own tab.
expect(plus('a').getAttribute('onclick')).toBe('event.stopPropagation(); app.openTabNewSessionMenu(event, "a")');
expect(openFrom(app, 'a')).toBe(true);
// gemini is not on this host and pi is disabled in the catalog.
expect(entries()).toEqual([
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
['run-mode-dot codex', 'Codex', 'run-mode-option selected'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
for (const dot of picker()!.querySelectorAll('.run-mode-dot')) expect(dot.getAttribute('aria-hidden')).toBe('true');
// The Run button's mode is the one preselected for the keyboard.
expect(document.activeElement?.textContent).toBe('Codex');
});
it("a Docker case offers the container's CLIs through the Run menu's rule, never the host's", () => {
const app = makeApp();
// Adopted, not probed yet: the list stored at adoption (gemini, which the host lacks; no claude, which it has).
openFrom(app, 'c');
expect(entries()).toEqual([
['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
app.closeTabGroupMenu();
// A live probe wins over the stored list.
app._dockerCaseModes = { box: ['claude', 'shell'] };
openFrom(app, 'c');
expect(entries()).toEqual([
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
app.closeTabGroupMenu();
// An unreadable adopted container offers no agent at all.
app._dockerCaseProbeError = { box: 'Could not read container "box".' };
openFrom(app, 'c');
expect(entries()).toEqual([['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option']]);
app.closeTabGroupMenu();
// An owned container with nothing probed is not gated: its image ships every CLI.
openFrom(app, 'd');
expect(entries()).toEqual([
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
['run-mode-dot codex', 'Codex', 'run-mode-option selected'],
['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
});
it("opening on a Docker tab whose container is not probed yet probes it, so the next open lists the container's CLIs", async () => {
const app = makeApp();
const probe = vi.spyOn(app, '_probeDockerCaseModes');
const previousFetch = win.fetch;
// The network boundary: what POST /api/docker-cases/adopt-preflight answers for this container.
win.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: { ok: true, availableModes: ['codex', 'shell'] } }),
}));
try {
probe.mockClear();
win.fetch.mockClear();
openFrom(app, 'c');
// This open reads what is already known (the list stored at adoption) and starts the probe.
expect(entries()).toEqual([
['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
expect(probe).toHaveBeenCalledTimes(1);
expect(probe.mock.calls[0][0]).toBe(CASES.find((c) => c.name === 'box'));
expect(probe.mock.calls[0][1]).toBeNull();
expect(win.fetch).toHaveBeenCalledTimes(1);
const [url, init] = win.fetch.mock.calls[0];
expect(url).toBe('/api/docker-cases/adopt-preflight');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toStrictEqual({ hostId: 'h1', container: 'box' });
await flush();
app.closeTabGroupMenu();
// The next open lists what the container answered, and probes no more.
probe.mockClear();
win.fetch.mockClear();
openFrom(app, 'c');
expect(entries()).toEqual([
['run-mode-dot codex', 'Codex', 'run-mode-option selected'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
expect(probe).not.toHaveBeenCalled();
expect(win.fetch).not.toHaveBeenCalled();
app.closeTabGroupMenu();
// An owned container with nothing probed is probed as well.
probe.mockClear();
openFrom(app, 'd');
expect(probe).toHaveBeenCalledTimes(1);
expect(probe.mock.calls[0][0]).toBe(CASES.find((c) => c.name === 'own'));
expect(probe.mock.calls[0][1]).toBeNull();
await flush();
app.closeTabGroupMenu();
// Local, remote and worktree tabs have no container to ask.
for (const id of ['a', 'g', 'r', 'f']) {
probe.mockClear();
win.fetch.mockClear();
openFrom(app, id);
expect(probe).not.toHaveBeenCalled();
expect(win.fetch).not.toHaveBeenCalled();
app.closeTabGroupMenu();
}
} finally {
win.fetch = previousFetch;
}
});
it('finds the case by what the session runs on, never a same-path case elsewhere', async () => {
const app = makeApp();
const runs: unknown[] = [];
app.run = vi.fn(async (options: unknown) => {
runs.push(structuredClone(options));
});
// A Docker session belongs to its container's case, not to the local case at the same path.
openFrom(app, 'c');
pick('Terminal / Shell');
// A local session at that path belongs to the local case, never the Docker one.
openFrom(app, 'g');
expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']);
pick('Terminal / Shell');
// A remote session belongs to the case on its own host, by path prefix.
openFrom(app, 'r');
pick('Terminal / Shell');
// A local session in a case inside another case belongs to the inner one.
openFrom(app, 'n');
pick('Terminal / Shell');
await flush();
expect(runs).toStrictEqual([
{ count: 1, caseName: 'box', mode: 'shell' },
{ count: 1, caseName: 'clash', mode: 'shell' },
{ count: 1, caseName: 'rem', mode: 'shell' },
{ count: 1, caseName: 'nested', mode: 'shell' },
]);
});
it('a tab whose folder is no case says so and opens nothing', () => {
const app = makeApp();
expect(openFrom(app, 'e')).toBe(false);
expect(picker()).toBeNull();
expect(app.showToast).toHaveBeenCalledWith(
"This tab's folder is not a case, so there is nothing to start beside it.",
'info'
);
});
});
describe('the "+" from the keyboard', () => {
/**
* Wire the "+"'s inline keydown handler the way a browser wires an on* attribute
* (the attribute's text as the body of a function of `event`, `this` the
* element, `app` the page's global), since this JSDOM does not run inline handlers.
*/
function wireInlineKeydown(app: Record<string, any>, el: HTMLElement) {
win.app = app;
const body = el.getAttribute('onkeydown');
expect(body).not.toBeNull();
const handler = new win.Function('event', body);
el.addEventListener('keydown', function (this: HTMLElement, event: Event) {
handler.call(this, event);
});
}
const press = (el: HTMLElement, key: string) => {
const event = new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
el.dispatchEvent(event);
return event;
};
it('Enter and Space on the header strip\'s "+" open its picker, and the key goes no further', () => {
const app = makeApp({ orientation: 'horizontal' });
const strip = document.getElementById('sessionTabs')!;
// The header strip: a flat tablist, no row menu to fall back on.
expect(strip.getAttribute('role')).toBe('tablist');
const open = vi.spyOn(app, 'openTabNewSessionMenu');
const reachedStrip = vi.fn();
strip.addEventListener('keydown', reachedStrip);
wireInlineKeydown(app, plus('a'));
for (const key of ['Enter', ' ']) {
open.mockClear();
reachedStrip.mockClear();
const event = press(plus('a'), key);
expect(open).toHaveBeenCalledTimes(1);
expect(open.mock.calls[0][0]).toBe(event);
expect(open.mock.calls[0][1]).toBe('a');
expect(event.defaultPrevented).toBe(true);
expect(reachedStrip).not.toHaveBeenCalled();
expect(entries()).toEqual([
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
['run-mode-dot codex', 'Codex', 'run-mode-option selected'],
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
]);
// Anchored on the "+" itself; Escape gives focus back to its tab row (the shared menu's design).
expect(app._tabGroupMenuTrigger).toBe(plus('a'));
app.closeTabGroupMenu();
}
strip.removeEventListener('keydown', reachedStrip);
});
it('on a tab whose folder is no case the key still stops at the "+", so Space does not scroll the page', () => {
const app = makeApp({ orientation: 'horizontal' });
const strip = document.getElementById('sessionTabs')!;
const reachedStrip = vi.fn();
strip.addEventListener('keydown', reachedStrip);
wireInlineKeydown(app, plus('e'));
app.showToast.mockClear();
const event = press(plus('e'), ' ');
expect(event.defaultPrevented).toBe(true);
expect(reachedStrip).not.toHaveBeenCalled();
expect(picker()).toBeNull();
expect(app.showToast).toHaveBeenCalledWith(
"This tab's folder is not a case, so there is nothing to start beside it.",
'info'
);
strip.removeEventListener('keydown', reachedStrip);
});
it('every other key leaves the "+" alone and reaches the strip as before', () => {
const app = makeApp({ orientation: 'horizontal' });
const strip = document.getElementById('sessionTabs')!;
const open = vi.spyOn(app, 'openTabNewSessionMenu');
const reachedStrip = vi.fn();
strip.addEventListener('keydown', reachedStrip);
wireInlineKeydown(app, plus('a'));
for (const key of ['a', 'ArrowRight', 'ArrowDown', 'Escape', 'Tab', 'F2']) {
open.mockClear();
reachedStrip.mockClear();
const event = press(plus('a'), key);
expect(open).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
expect(reachedStrip).toHaveBeenCalledTimes(1);
expect(picker()).toBeNull();
}
strip.removeEventListener('keydown', reachedStrip);
});
it('a held Enter or Space that opened the picker cannot also pick its focused entry', async () => {
const app = makeApp({ orientation: 'horizontal' });
const runs: unknown[] = [];
app.run = vi.fn(async (options: unknown) => {
runs.push(structuredClone(options));
});
wireInlineKeydown(app, plus('a'));
for (const key of ['Enter', ' ']) {
press(plus('a'), key);
const focused = document.activeElement as HTMLElement;
expect(focused.textContent).toBe('Codex');
// The key's auto-repeat lands on the entry the picker just focused: its
// native activation is cancelled, so nothing launches and the picker stays.
const repeat = new win.KeyboardEvent('keydown', { key, repeat: true, bubbles: true, cancelable: true });
focused.dispatchEvent(repeat);
expect(repeat.defaultPrevented).toBe(true);
expect(picker()).not.toBeNull();
// A fresh press on an entry keeps its native activation.
const fresh = new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
focused.dispatchEvent(fresh);
expect(fresh.defaultPrevented).toBe(false);
app.closeTabGroupMenu();
}
// Other repeated keys keep their own handling (ArrowDown still moves).
press(plus('a'), 'Enter');
const arrow = new win.KeyboardEvent('keydown', { key: 'ArrowDown', repeat: true, bubbles: true, cancelable: true });
document.activeElement!.dispatchEvent(arrow);
expect(arrow.defaultPrevented).toBe(true);
expect(document.activeElement?.textContent).toBe('Terminal / Shell');
app.closeTabGroupMenu();
await flush();
expect(runs).toStrictEqual([]);
});
});
describe('a pick from the "+"', () => {
function recordRuns(app: Record<string, any>) {
const runs: Array<{ options: unknown; runMode: string }> = [];
app.run = vi.fn(async (options: unknown) => {
// Cloned into this realm, so toStrictEqual compares keys and values, not JSDOM's prototypes.
runs.push({ options: structuredClone(options), runMode: app._runMode });
});
return runs;
}
it("starts ONE session in the tab's case and named group, then leaves the Run mode and case alone", async () => {
const app = makeApp();
const runs = recordRuns(app);
app.collapsedTabGroupIds.add('gx');
openFrom(app, 'b');
pick('Claude Code');
await flush();
// Its group is opened, so the new tab is visible when it arrives.
expect(app.collapsedTabGroupIds.has('gx')).toBe(false);
// The mode rides the launch; the Run button's own mode is never swapped.
expect(runs).toStrictEqual([
{ options: { count: 1, caseName: 'proj', tabGroupId: 'gx', mode: 'claude' }, runMode: 'codex' },
]);
expect(picker()).toBeNull();
expect(app._runMode).toBe('codex');
expect((document.getElementById('quickStartCase') as HTMLSelectElement).value).toBe('toolbar-case');
});
it('from an ungrouped tab it sends no group at all, so the case default group can place it', async () => {
const app = makeApp();
const runs = recordRuns(app);
openFrom(app, 'a');
pick('Terminal / Shell');
await flush();
expect(runs).toStrictEqual([{ options: { count: 1, caseName: 'proj', mode: 'shell' }, runMode: 'codex' }]);
});
it('from a tab in a managed worktree it starts in that worktree, not the case folder', async () => {
const app = makeApp();
const runs = recordRuns(app);
openFrom(app, 'f');
pick('Codex');
await flush();
expect(runs).toStrictEqual([
{
options: {
count: 1,
worktree: { caseName: 'proj', name: 'feat', path: '/tmp/proj/.claude/worktrees/feat' },
mode: 'codex',
},
runMode: 'codex',
},
]);
});
it('refuses while another launch is in flight, and says so', async () => {
const app = makeApp();
const runs = recordRuns(app);
openFrom(app, 'a');
app._runInFlight = true;
app.showToast.mockClear();
pick('Codex');
await flush();
expect(runs).toEqual([]);
expect(app.showToast).toHaveBeenCalledWith('A launch is already in progress', 'info');
expect(app._runMode).toBe('codex');
});
it("a launch the server refuses reports the server's own error", async () => {
const app = makeApp();
app._runMode = 'claude';
const posted: Array<{ url: string; body: unknown }> = [];
win.fetch = vi.fn(async (url: string, init?: { body?: string }) => {
const body = init?.body ? JSON.parse(init.body) : undefined;
posted.push({ url, body });
const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value });
if (url === '/api/codex/status') return json({ success: true, data: { available: true, runnable: true } });
if (url === '/api/quick-start') {
return json({ success: false, error: 'Case "proj" has no free session slot', errorCode: 'CONFLICT' });
}
return json({ success: true, data: {} });
});
openFrom(app, 'a');
app.showToast.mockClear();
pick('Codex');
await flush();
expect(posted.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
caseName: 'proj',
mode: 'codex',
});
expect(app.showToast).toHaveBeenLastCalledWith('Case "proj" has no free session slot', 'error');
expect(app._runMode).toBe('claude');
});
it('a Run mode chosen while the launch is pending is kept, on screen and on disk', async () => {
const app = makeApp();
app._applyRunMode = () => {};
app._apiPut = vi.fn(async () => ({ success: true }));
let finish: () => void = () => {};
app.runShell = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
openFrom(app, 'a');
pick('Terminal / Shell');
await flush();
expect(app.runShell).toHaveBeenCalledTimes(1);
app.setRunMode('claude');
finish();
await flush();
expect(app._runMode).toBe('claude');
expect(win.localStorage.getItem('codeman_runMode')).toBe('claude');
expect(app._apiPut).toHaveBeenCalledWith('/api/settings', { runMode: 'claude' });
});
/**
* Readies a Claude launch with the shipping case-list reload and the server's
* lastUsedCase at `lastUsedCase`; the returned wait resolves once that reload ran.
*/
function useShippingCaseReload(app: Record<string, any>, lastUsedCase: string) {
app._runMode = 'claude';
app.getAdvisorSetting = () => undefined;
app._onSessionCreated = () => {};
delete app.loadQuickStartCases; // the shipping reload, not makeApp's no-op
// The UI-only steps of the case list reload.
for (const name of ['updateDirDisplayForCase', 'updateMobileCaseLabel', 'updateCasePickerInput'])
app[name] = () => {};
for (const name of ['renderCasePickerList', 'closeCasePicker', 'setupQuickStartCasePicker']) app[name] = () => {};
win.fetch = vi.fn(async (url: string) => {
const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value });
// quick-start saved the launched case as lastUsedCase (session-routes.ts).
if (url === '/api/settings') return json({ success: true, data: { lastUsedCase } });
if (url === '/api/cases') return json({ success: true, data: CASES });
if (url.startsWith('/api/cases/')) {
const record = CASES.find((c) => `/api/cases/${c.name}` === url);
return json(record ? { success: true, data: record } : { success: false, errorCode: 'NOT_FOUND' });
}
if (url === '/api/quick-start')
return json({ success: true, data: { sessionId: 'new1', session: { id: 'new1' } } });
if (url === '/api/sessions') return json({ success: true, data: { session: { id: 'new1', name: 'w1' } } });
return json({ success: true, data: {} });
});
return async () => {
await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1'));
// The case list is reloaded after the launch (not awaited by it).
await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases'));
await flush();
};
}
it.each([
['a remote Claude launch (quick-start)', 'rem', 'r'],
['a local Claude launch', 'proj', 'a'],
])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => {
const app = makeApp();
const reloaded = useShippingCaseReload(app, tabCase);
const select = document.getElementById('quickStartCase') as HTMLSelectElement;
openFrom(app, id);
pick('Claude Code');
await reloaded();
expect(select.value).toBe('toolbar-case');
});
// An earlier "+" launch through quick-start left lastUsedCase on the tab's case
// (proj); a later Claude launch that names no case must not follow it there.
it.each([
[
'a "+" Claude launch from a managed worktree tab',
'horizontal',
(app: Record<string, any>) => {
openFrom(app, 'f');
pick('Claude Code');
},
],
['the toolbar Run with Claude', 'horizontal', (app: Record<string, any>) => app.run()],
[
'a group\'s "New session" with Claude',
'vertical',
(app: Record<string, any>) => app.createSessionInTabGroup('gx'),
],
])('%s keeps the toolbar on its own case after a "+" moved lastUsedCase', async (_label, orientation, launch) => {
const app = makeApp({ orientation });
const reloaded = useShippingCaseReload(app, 'proj');
const select = document.getElementById('quickStartCase') as HTMLSelectElement;
(document.getElementById('tabCount') as HTMLInputElement).value = '1';
void launch(app);
await reloaded();
expect(select.value).toBe('toolbar-case');
});
});
describe('"New session here…" in the row menu', () => {
const rowMenu = (app: Record<string, any>, trigger: HTMLElement, id: string) =>
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: trigger }, id);
it('is offered on a session row only while the "+" is switched on, and opens the same picker', () => {
const app = makeApp({ orientation: 'vertical' });
rowMenu(app, tab('a'), 'a');
expect(rowMenuLabels()).toEqual([
'Session options',
'Add to Focus',
'Move down',
'Move to "Core"',
'Move to new group',
'Close session',
]);
app.closeTabRailActionMenu();
document.documentElement.classList.add('tabs-show-new');
rowMenu(app, tab('a'), 'a');
expect(rowMenuLabels()).toEqual([
'Session options',
'New session here…',
'Add to Focus',
'Move down',
'Move to "Core"',
'Move to new group',
'Close session',
]);
[...document.querySelectorAll<HTMLElement>('.tab-rail-action-menu button')]
.find((button) => button.textContent === 'New session here…')!
.click();
expect(rowMenuLabels()).toEqual([]);
expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']);
// Escape gives the keyboard back to the tab row.
document.activeElement!.dispatchEvent(
new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
);
expect(picker()).toBeNull();
expect(document.activeElement).toBe(tab('a'));
});
it("is never offered on a tile's menu, which shares the function", () => {
const app = makeApp({ orientation: 'vertical' });
document.documentElement.classList.add('tabs-show-new');
const tileButton = document.createElement('button');
tileButton.className = 'tile-menu';
document.body.appendChild(tileButton);
rowMenu(app, tileButton, 'a');
expect(rowMenuLabels()).toEqual([
'Session options',
'Add to Focus',
'Move down',
'Move to "Core"',
'Move to new group',
'Close session',
]);
});
});
describe('picker lifetime', () => {
it('a full re-render closes it and drops its document listeners', () => {
const app = makeApp();
openFrom(app, 'a');
expect(picker()).not.toBeNull();
app.sessions.delete('a');
app._fullRenderSessionTabs();
expect(picker()).toBeNull();
expect(app._tabGroupMenuOutside).toBeNull();
expect(app._tabGroupMenuKeydown).toBeNull();
expect(app._tabGroupMenuResize).toBeNull();
});
it('pressing the same "+" again closes it, like the row menu', () => {
const app = makeApp();
openFrom(app, 'a');
expect(openFrom(app, 'a')).toBe(false);
expect(picker()).toBeNull();
});
});
describe('showTabNewButton stays per-device and OFF by default', () => {
const settingsUi = read('settings-ui.js');
const index = read('index.html');
it('is a display key, stripped from the settings PUT, which would refuse it', () => {
const start = settingsUi.indexOf('const displayKeys = new Set([');
expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'showTabNewButton'");
expect(settingsUi).toContain('showTabNewButton: _tnb,');
expect(SettingsUpdateSchema.safeParse({ showTabNewButton: true }).success).toBe(false);
});
it('loads OFF unless switched on, saves from its switch, and drives the class the CSS reads', () => {
expect(settingsUi).toContain(
"document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;"
);
expect(settingsUi).toContain("showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,");
expect(settingsUi).toContain(
"document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);"
);
expect(index).toContain('<input type="checkbox" id="appSettingsShowTabNewButton">');
});
it('translates every string it adds', () => {
const i18n = read('i18n.js');
for (const text of [
"'New session here'",
"'New session here…'",
'"New session in this tab\'s case"',
'"This tab\'s folder is not a case, so there is nothing to start beside it."',
"'New Session Button on Tabs'",
"'A + on the tab that starts a session in the same case and group, in any installed CLI.'",
]) {
expect(i18n).toContain(`${text}:`);
}
});
});