Files
Codeman/test/worktree-ui.test.ts
T
JD c7835a159c feat(tabs): start a session beside any tab from an opt-in + in its actions
Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case.

The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons.

The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
2026-10-24 20:29:00 -04:00

189 lines
8.7 KiB
TypeScript

/**
* @fileoverview The git worktree launch wiring in session-ui.js, in a vm sandbox (no real DOM),
* the way test/run-mode-ui.test.ts drives the run*() methods:
*
* - `managedWorktreeOfDir()` names only `<local case path>/.claude/worktrees/<valid-name>`;
* - 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;
* - `_launchOnce()` starts the one launch in the chosen mode without touching the Run button's mode.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const SESSION_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
const WT = { caseName: 'myrepo', name: 'fix-login', path: '/cases/myrepo/.claude/worktrees/fix-login' };
function load(fetchImpl: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) {
const elements: Record<string, any> = {
// The toolbar still points at ANOTHER case: a worktree launch must not read it.
quickStartCase: { value: 'other-case' },
tabCount: { value: '3' },
};
const requests: Array<{ url: string; method?: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string, init?: { method?: string; body?: string }) => {
requests.push({ url, method: init?.method, body: init?.body ? JSON.parse(init.body) : undefined });
return fetchImpl(url, init);
},
console,
setTimeout,
});
vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.sessions = new Map();
app.cases = [
{ name: 'myrepo', path: '/cases/myrepo', location: 'local' },
{ name: 'dock', path: '/cases/dock', location: 'docker' },
];
app.getCaseSettings = () => ({});
app.loadAppSettingsFromStorage = () => ({});
app.buildEnvOverrides = () => ({});
app.getEffortSetting = () => undefined;
app.getAdvisorSetting = () => undefined;
app.isRalphTrackerEnabledByDefault = () => false;
app.getTerminalDimensions = () => null;
app.loadQuickStartCases = () => {};
app.isCliAvailable = () => true;
app._ensureCreatedSessionVisible = async () => {};
app.selectSession = vi.fn(async () => {});
return { app, context, requests };
}
const json = (data: unknown) => ({ ok: true, status: 200, json: async () => data });
describe('managedWorktreeOfDir', () => {
const { context } = load(async () => json({}));
const fn = (context as unknown as { managedWorktreeOfDir: (d: unknown, c: unknown) => unknown }).managedWorktreeOfDir;
const cases = [
{ name: 'myrepo', path: '/cases/myrepo', location: 'local' },
{ name: 'linked', path: '/srv/linked', location: 'linked-local' },
{ name: 'dock', path: '/cases/dock', location: 'docker' },
];
it('names a worktree folder of a local or linked case', () => {
expect(fn('/cases/myrepo/.claude/worktrees/fix-login', cases)).toEqual(WT);
expect(fn('/srv/linked/.claude/worktrees/a.b_c/', cases)).toEqual({
caseName: 'linked',
name: 'a.b_c',
path: '/srv/linked/.claude/worktrees/a.b_c',
});
});
it('ignores the case folder, a subfolder, unknown or Docker cases and invalid names', () => {
expect(fn('/cases/myrepo', cases)).toBeNull();
expect(fn('/cases/myrepo/.claude/worktrees/fix-login/src', cases)).toBeNull();
expect(fn('/elsewhere/.claude/worktrees/x', cases)).toBeNull();
expect(fn('/cases/dock/.claude/worktrees/x', cases)).toBeNull();
expect(fn('/cases/myrepo/.claude/worktrees/-x', cases)).toBeNull();
expect(fn('/cases/myrepo/.claude/worktrees/a..b', cases)).toBeNull();
expect(fn(undefined, cases)).toBeNull();
});
});
describe('launching in a worktree', () => {
it('claude: POST /api/sessions in the worktree folder, one tab named after the worktree', async () => {
const { app, requests } = load(async (url) => {
if (url === '/api/sessions') return json({ success: true, data: { session: { id: 's-1' } } });
return json({ success: true });
});
await app.runClaude({ count: 1, worktree: WT });
const create = requests.filter((r) => r.url === '/api/sessions');
expect(create).toHaveLength(1);
expect(create[0].body).toMatchObject({ workingDir: WT.path, name: 'w1-fix-login' });
expect(requests.some((r) => r.url.startsWith('/api/cases'))).toBe(false);
expect(requests.some((r) => r.url === '/api/sessions/s-1/interactive')).toBe(true);
expect(app.selectSession).toHaveBeenCalledWith('s-1');
});
it('shell: POST /api/sessions in the worktree folder, no case lookup', async () => {
const { app, requests } = load(async (url) => {
if (url === '/api/sessions') return json({ success: true, data: { session: { id: 's-2' } } });
return json({ success: true });
});
await app.runShell({ count: 1, worktree: WT });
expect(requests.find((r) => r.url === '/api/sessions')?.body).toMatchObject({
workingDir: WT.path,
mode: 'shell',
name: 's1-fix-login',
});
expect(requests.some((r) => r.url.startsWith('/api/cases'))).toBe(false);
});
it('other CLIs: quick-start with the case and the worktree name', async () => {
const { app, requests } = load(async (url) => {
if (url === '/api/codex/status') return json({ success: true, data: { available: true } });
if (url === '/api/quick-start') return json({ success: true, data: { sessionId: 's-3' } });
return json({ success: true });
});
await app._runCliMode('codex', { count: 1, worktree: WT });
const qs = requests.filter((r) => r.url === '/api/quick-start');
expect(qs).toHaveLength(1);
expect(qs[0].body).toMatchObject({
caseName: 'myrepo',
mode: 'codex',
worktree: 'fix-login',
sessionName: 'w1-fix-login',
});
});
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._launchOnce('codex', { worktree: WT });
expect(seen).toEqual([{ mode: 'claude', options: { count: 1, worktree: WT, mode: 'codex' } }]);
expect(app._runMode).toBe('claude');
});
it('a worktree whose folder is gone offers Remove but not Open, with a note saying what Remove does', () => {
const dom = new JSDOM('<!doctype html><body></body>');
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: dom.window.document,
console,
setTimeout,
});
vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app._shortenHomePath = (p: string) => p;
const row = (wt: Record<string, unknown>) =>
app._buildWorktreeRow({ branch: 'worktree-x', path: '/r/.claude/worktrees/x', name: 'x', ...wt });
const buttons = (el: HTMLElement) => [...el.querySelectorAll('button')].map((b) => b.textContent);
expect(buttons(row({ managed: true, prunable: false }))).toEqual(['Open', 'Remove…']);
expect(buttons(row({ managed: true, prunable: true }))).toEqual(['Remove…']);
expect(buttons(row({ managed: false, prunable: true }))).toEqual([]);
app._worktreeRemoveConfirm = 'x';
const missingNote = row({ managed: true, prunable: true }).querySelector('.worktree-remove-note')!.textContent;
expect(missingNote).toContain('folder is gone');
const liveNote = row({ managed: true, prunable: false }).querySelector('.worktree-remove-note')!.textContent;
expect(liveNote).toContain('never forces');
dom.window.close();
});
it('closing offers worktree removal only for a session in a managed worktree', () => {
const { app } = load(async () => json({}));
expect(app._managedWorktreeOfSession({ workingDir: WT.path })).toEqual(WT);
expect(app._managedWorktreeOfSession({ workingDir: '/cases/myrepo' })).toBeNull();
expect(app._managedWorktreeOfSession({ workingDir: WT.path, remote: { hostId: 'x' } })).toBeNull();
});
});