mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
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.
156 lines
7.0 KiB
TypeScript
156 lines
7.0 KiB
TypeScript
/**
|
|
* @fileoverview Launching a session INTO a tab group (the group menu's "New
|
|
* session"): session-ui.js run paths with `{ count, tabGroupId }`.
|
|
*
|
|
* Pins that the group id rides the create request of every launch path (local
|
|
* /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), 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).
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const LAYOUT = { version: 9, updatedAt: '', groups: [{ id: 'g1', name: 'Core', refs: [] }], ungrouped: [] };
|
|
|
|
function harness(caseData: Record<string, unknown>, extra: { mode?: string } = {}) {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'proj' },
|
|
tabCount: { value: '4' },
|
|
shellCount: { value: '3' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const events: string[] = [];
|
|
let counter = 0;
|
|
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?: { body?: string }) => {
|
|
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
requests.push({ url, body });
|
|
const json = (value: unknown) => ({ json: async () => value });
|
|
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}`;
|
|
return json({
|
|
success: true,
|
|
data: { session: { id, name: body.name }, ...(body.tabGroupId ? { tabLayout: LAYOUT } : {}) },
|
|
});
|
|
}
|
|
if (url === '/api/quick-start') {
|
|
const id = `sess-${++counter}`;
|
|
return json({
|
|
success: true,
|
|
data: { sessionId: id, session: { id }, ...(body.tabGroupId ? { tabLayout: LAYOUT } : {}) },
|
|
});
|
|
}
|
|
return json({ success: true, data: {} });
|
|
},
|
|
console,
|
|
});
|
|
for (const file of ['settings-ui.js', 'session-ui.js']) {
|
|
vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8'), context, {
|
|
filename: file,
|
|
});
|
|
}
|
|
const app = new (CodemanApp as any)();
|
|
app._runMinLockMs = 0;
|
|
app._runMode = extra.mode ?? 'claude';
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 };
|
|
app.sessions = new Map();
|
|
app.cases = [{ name: 'proj', location: caseData.location }];
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.getEffortSetting = () => undefined;
|
|
app.isRalphTrackerEnabledByDefault = () => false;
|
|
app.getTerminalDimensions = () => null;
|
|
app.isCliAvailable = () => true;
|
|
app.loadQuickStartCases = () => {};
|
|
app.selectSession = vi.fn(async () => {});
|
|
app._applyTabLayout = vi.fn(() => events.push('layout'));
|
|
app._onSessionCreated = (session: any) => {
|
|
events.push(`created:${session.id}`);
|
|
app.sessions.set(session.id, session);
|
|
};
|
|
app._renderSessionTabsImmediate = () => events.push('render');
|
|
return { app, elements, requests, events };
|
|
}
|
|
|
|
const creates = (requests: Array<{ url: string; body?: any }>) =>
|
|
requests.filter((req) => req.url === '/api/sessions' || req.url === '/api/quick-start');
|
|
|
|
describe('launching into a tab group', () => {
|
|
it('local Claude: one session with the group id, and the returned layout adopted before the tab renders', async () => {
|
|
const { app, elements, requests, events } = harness({ name: 'proj', path: '/tmp/proj' });
|
|
await app.run({ count: 1, tabGroupId: 'g1' });
|
|
const made = creates(requests);
|
|
expect(made).toHaveLength(1);
|
|
expect(made[0].url).toBe('/api/sessions');
|
|
expect(made[0].body.tabGroupId).toBe('g1');
|
|
expect(elements.tabCount.value).toBe('4');
|
|
expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT);
|
|
expect(events.slice(0, 3)).toEqual(['layout', 'created:sess-1', 'render']);
|
|
});
|
|
|
|
it('local Shell honours the same one-session override', async () => {
|
|
const { app, elements, requests } = harness({ name: 'proj', path: '/tmp/proj' }, { mode: 'shell' });
|
|
await app.run({ count: 1, tabGroupId: 'g1' });
|
|
const made = creates(requests);
|
|
expect(made).toHaveLength(1);
|
|
expect(made[0].body).toMatchObject({ mode: 'shell', tabGroupId: 'g1' });
|
|
expect(elements.shellCount.value).toBe('3');
|
|
});
|
|
|
|
it.each([
|
|
['remote Claude', { name: 'proj', path: 'u@h:/w', location: 'remote' }, 'claude'],
|
|
['remote Shell', { name: 'proj', path: 'u@h:/w', location: 'remote' }, 'shell'],
|
|
['an external CLI', { name: 'proj', path: '/tmp/proj' }, 'gemini'],
|
|
])('%s carries the group on its quick-start request', async (_label, caseData, mode) => {
|
|
const { app, requests } = harness(caseData, { mode });
|
|
await app.run({ count: 1, tabGroupId: 'g1' });
|
|
const made = creates(requests);
|
|
expect(made).toHaveLength(1);
|
|
expect(made[0].url).toBe('/api/quick-start');
|
|
expect(made[0].body.tabGroupId).toBe('g1');
|
|
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();
|
|
const made = creates(requests);
|
|
expect(made).toHaveLength(4);
|
|
expect(made.every((req) => !('tabGroupId' in req.body))).toBe(true);
|
|
expect(app._applyTabLayout).not.toHaveBeenCalled();
|
|
});
|
|
});
|