Merge pull request #712: feat(tabs): start a session beside any tab from an opt-in + in its actions

This commit is contained in:
Codeman maintainer committed 2026-10-27 02:24:17 +01:00
commit 0f1fca6ea0
18 files changed
+1266 -46

No files matched your search

+11
View File
@@ -162,6 +162,17 @@ describe('phone tab tap zones', () => {
expect(phoneDeclarations('.session-tab .tab-gear').display).toBe('none');
});
it('with the opt-in "+" switched on, the wider reserve clears all three icons', () => {
// showTabNewButton adds a third icon to the active phone tab (html.tabs-show-new)
// and a larger reserve to go with it; the same arithmetic must still hold.
const plus = phoneDeclarations('html.tabs-show-new .session-tab.active .tab-new');
const wide = len(phoneDeclarations('html.tabs-show-new .session-tab.active .tab-name')['min-width']);
expect(len(plus.width)).toBeGreaterThanOrEqual(28);
expect(wide).toBeGreaterThanOrEqual(requiredReserve() + len(plus.width) + ROUNDING_ALLOWANCE);
expect(wide).toBeLessThanOrEqual(len(nameShared['max-width']));
expect(phoneDeclarations('.session-tab .tab-new').display).toBe('none');
});
it('the pop-out icon stays out of the cluster on handhelds', () => {
// `icons` above counts gear + close only. If detach ever became visible on a
// phone the reserve would be ~30px short and the centre would walk back.
+111 -1
View File
@@ -3,13 +3,16 @@
* read and write it, and the create routes that carry the case into the real
* TabLayoutService so a new session lands in its case's group in the creating
* write (POST /api/sessions, and POST /api/quick-start for local, remote and
* Docker cases).
* Docker cases). The tab "+" picker (app.js) is driven end to end against the
* same routes, since an ungrouped tab relies on this fallback by design.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
@@ -101,6 +104,56 @@ const unwrap = (raw: string) => {
return parsed.data ?? parsed;
};
/**
* The shipping frontend in JSDOM, with fetch answered by the route harnesses, so
* a click in the tab "+" picker reaches the real create route and layout service.
*/
async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) {
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));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = dom.window as any;
win.setInterval = () => 0;
win.requestAnimationFrame = () => 0;
win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }];
const publicDir = join(process.cwd(), 'src/web/public');
win.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js']
.map((file) => readFileSync(join(publicDir, file), 'utf8'))
.join('\n;\n') +
'\n;window.__CdgCodemanApp = CodemanApp;'
);
win.fetch = async (url: string, init?: { method?: string; body?: string }) => {
const body = await route(url, init);
return { ok: true, status: 200, json: async () => body };
};
win.document.body.innerHTML = '<div id="sessionTabs"></div><select id="quickStartCase"></select>';
const app = Object.create(win.__CdgCodemanApp.prototype);
Object.assign(app, {
$: (id: string) => win.document.getElementById(id),
sessions: new Map(),
activeSessionId: 'tab-1',
terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 },
_runMinLockMs: 0,
_runMode: 'claude',
getTerminalDimensions: () => null,
selectSession: async () => {},
loadQuickStartCases: () => {},
showToast: vi.fn(),
_applyTabLayout: () => {},
_onSessionCreated: () => {},
_renderSessionTabsImmediate: () => {},
});
return { app, win };
}
describe('case default group: create routes', () => {
let harness: RouteTestHarness;
let world: World;
@@ -280,6 +333,63 @@ describe('case default group: create routes', () => {
// The route's own error path ran cleanupSession for the registered id.
expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1);
});
describe('from a tab "+" in the browser', () => {
/** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */
async function launchBeside(tabGroupId: string | null) {
const cases = await createRouteTestHarness(registerCaseRoutes);
const creates: Array<Record<string, unknown>> = [];
try {
const { app, win } = await bootTabApp(async (url, init) => {
const payload = init?.body ? JSON.parse(init.body) : undefined;
if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload);
const target = url.startsWith('/api/cases/') ? cases : harness;
const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload });
// The envelope server.ts's preSerialization hook adds, which the harness does not install.
const body = res.body ? JSON.parse(res.body) : {};
return typeof body.success === 'boolean' ? body : { success: true, data: body };
});
app.cases = [{ name: CASE, path: caseDir }];
app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir });
app.tabLayout = {
...world.layouts[OWNER],
groups: world.layouts[OWNER].groups.map((g) => ({
...g,
refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [],
})),
};
app.collapsedTabGroupIds = new Set();
app.toggleTabGroupCollapsed = () => {};
const plus = win.document.createElement('span');
win.document.body.appendChild(plus);
app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1');
const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find(
(button: Element) => button.textContent === 'Terminal / Shell'
) as HTMLElement;
shell.click();
await vi.waitFor(() => expect(creates).toHaveLength(1));
await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1));
const [id] = [...harness.ctx.sessions.keys()];
return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls };
} finally {
await cases.app.close();
}
}
it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => {
const { create, group, toasts } = await launchBeside(null);
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` });
expect(group).toBe('g-ops');
// The launch went through: no error reported along the way.
expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]);
});
it('a grouped tab sends its own group, which wins over the case default', async () => {
const { create, group } = await launchBeside('g-core');
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' });
expect(group).toBe('g-core');
});
});
});
describe('case default group: GET/PUT /api/cases/:name/default-group', () => {
+22 -2
View File
@@ -6,7 +6,8 @@
* /api/sessions, remote/docker and external-CLI /api/quick-start), that the
* one-session count overrides the toolbar steppers without touching them, that
* the layout returned by the create response is adopted BEFORE the new tab is
* drawn (so it is drawn inside its group), and that an ordinary Run sends no
* drawn (so it is drawn inside its group), that a tab's "+" (`caseName`) starts
* in that case rather than the toolbar's, and that an ordinary Run sends no
* group at all.
*
* Port: none (vm sandbox, stubbed fetch).
@@ -36,7 +37,7 @@ function harness(caseData: Record<string, unknown>, extra: { mode?: string } = {
const body = init?.body ? JSON.parse(init.body) : undefined;
requests.push({ url, body });
const json = (value: unknown) => ({ json: async () => value });
if (url === '/api/cases/proj') return json({ success: true, data: caseData });
if (url.startsWith('/api/cases/')) return json({ success: true, data: caseData });
if (url.endsWith('/status')) return json({ success: true, data: { available: true, runnable: true } });
if (url === '/api/sessions') {
const id = `sess-${++counter}`;
@@ -124,6 +125,25 @@ describe('launching into a tab group', () => {
expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT);
});
// A tab's "+" launches beside that tab: its case, never the toolbar's selected one.
it.each([
// Claude's create body names no mode: it is the server's default.
['local Claude', 'claude', '/api/sessions', { workingDir: '/tmp/other' }],
['local Shell', 'shell', '/api/sessions', { mode: 'shell', workingDir: '/tmp/other' }],
['an external CLI', 'gemini', '/api/quick-start', { mode: 'gemini', caseName: 'other' }],
])('%s launches in `caseName` instead of the toolbar case', async (_label, mode, url, where) => {
const { app, elements, requests } = harness({ name: 'other', path: '/tmp/other' }, { mode });
await app.run({ count: 1, caseName: 'other' });
const made = creates(requests);
expect(made).toHaveLength(1);
expect(made[0].url).toBe(url);
expect(made[0].body).toMatchObject(where);
expect(JSON.stringify(made[0].body)).not.toContain('proj');
// The local paths look the case up by the given name, never the toolbar's.
if (url === '/api/sessions') expect(requests[0].url).toBe('/api/cases/other');
expect(elements.quickStartCase.value).toBe('proj');
});
it('an ordinary Run sends no group and keeps the stepper count', async () => {
const { app, requests } = harness({ name: 'proj', path: '/tmp/proj' });
await app.run();
+16
View File
@@ -910,6 +910,22 @@ describe('group menu', () => {
expect(app._tabGroupMenuKeydown).toBeNull();
});
it("Safari's item click (a blur with nowhere to go) keeps the menu open, so the item still runs", () => {
// Safari never focuses a clicked button: the press blurs the focused item
// with relatedTarget null, and closing on that dropped the click.
installFetch();
const app = makeApp();
open(app);
app.startTabGroupRename = vi.fn();
const buttons = document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button');
const rename = [...buttons].find((button) => button.textContent === 'Rename group')!;
buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: null }));
expect(isOpen()).toBe(true);
rename.click();
expect(app.startTabGroupRename).toHaveBeenCalledWith('gx');
expect(isOpen()).toBe(false);
});
it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => {
installFetch();
const app = makeApp();
+839
View File
@@ -0,0 +1,839 @@
/**
* @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}:`);
}
});
});
+4 -4
View File
@@ -6,7 +6,7 @@
* - a worktree launch goes through the EXISTING create paths with the worktree's folder:
* POST /api/sessions with `workingDir` for claude and shell, quick-start with `worktree` for
* every other CLI; never a case lookup or scaffold, and tabs named after the worktree;
* - `_launchInWorktree()` swaps the run mode for the one launch and puts it back.
* - `_launchOnce()` starts the one launch in the chosen mode without touching the Run button's mode.
*
* Port: N/A.
*/
@@ -138,15 +138,15 @@ describe('launching in a worktree', () => {
});
});
it('_launchInWorktree runs ONE session of the chosen mode and restores the Run button mode', async () => {
it("_launchOnce runs ONE session of the chosen mode in the worktree and leaves the Run button's mode", async () => {
const { app } = load(async () => json({}));
app._runMode = 'claude';
const seen: Array<{ mode: string; options: unknown }> = [];
app.run = vi.fn(async (options: unknown) => {
seen.push({ mode: app._runMode, options });
});
await app._launchInWorktree(WT, 'codex');
expect(seen).toEqual([{ mode: 'codex', options: { count: 1, worktree: WT } }]);
await app._launchOnce('codex', { worktree: WT });
expect(seen).toEqual([{ mode: 'claude', options: { count: 1, worktree: WT, mode: 'codex' } }]);
expect(app._runMode).toBe('claude');
});