mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge pull request #712: feat(tabs): start a session beside any tab from an opt-in + in its actions
This commit is contained in:
commit
0f1fca6ea0
18 files changed
+1266
-46
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}:`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user