mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
New sessions, spawned child sessions and saved web tabs now land in tab
groups instead of always starting in Ungrouped.
New session in a group:
- The group menu gains "New session" (first item). It expands a collapsed
group and runs ONE launch with the current case and run mode; a launch
already in flight is reported, not queued.
- run({ count, tabGroupId }) threads the group through every launch path
(local POST /api/sessions, remote/docker and external-CLI
/api/quick-start). The count overrides the steppers for that launch only.
- Server: POST /api/sessions and /api/quick-start accept an optional
tabGroupId (Zod, 1-100 chars; null/empty is a 400). The routes pass it to
addSession(), and TabLayoutService.sessionCreated() places the session at
the end of that group in the SAME versioned write that first records it.
The session is added to the live map inside the owner lock (with a
rollback when the write fails), so no queued layout edit can commit it
first. The group is looked up in the creating owner's own layout, so an
unknown, deleted or foreign id is ignored instead of failing the spawn. An
explicit group is a hand placement: a child placed this way is manual.
- The create response carries the committed tabLayout (only when tabGroupId
was sent), adopted before the new tab is drawn. Responses without the
field are unchanged.
Child sessions (parentSessionId / X-Codeman-Parent-Session):
- A child nobody placed follows its parent: the server's existing
normalization already puts it after the parent's rows; the browser now
draws a not-yet-stored child there too (project({ parents })) and, while
groups exist, adopts the server's order instead of echoing its own append
back through PUT /api/session-order (which pinned every child manual).
- The row menu says where a child sits (follows, placed by hand, parent
closed, parent loop) and offers "Follow parent again" only to a hand-placed
child. A child whose parent is gone stands on its own and is never
re-adopted.
Saved web tabs:
- Web tabs are layout refs like sessions; openness stays per device. A
grouped web tab shows (and takes an Alt+N slot) only while open here and
reopens where the layout keeps it. Pinned by tests; no behaviour change.
Unchanged: the flat rail and header strip (markup, HTML5 drag, append and
save on create), the tree semantics and single tab stop, sessionOrder and
Alt+N, and every request that does not send tabGroupId.
136 lines
5.9 KiB
TypeScript
136 lines
5.9 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), 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 === '/api/cases/proj') 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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|