fix(run,terminal): merge-time fixes for the Instance count stepper and capture geometry

#454: the behaviour the PR adds had no test, so a regression test drives
runGrok() at tabCount 3 and asserts three quick-start POSTs with sequential
w<n>-<case> names (verified to fail against master's session-ui.js). Each
caller now reads the count BEFORE its opening banner and announces it there,
the way runClaude() already did, so a launch no longer prints two headers and
a launch with another session already active still says how many are starting.
runClaude() calls the shared _readTabCount() instead of its own copy of the
1..20 clamp, and that helper optional-chains the element read, since hoisting
it above each caller's try block would otherwise let a missing #tabCount throw
where the launch-error path cannot report it.

#435: sizeMovedUnderLoad derived from data.source alone. `mux-visible` is not
sufficient: a failed display-message cursor query makes capturePaneBuffer skip
the snapshot repaint and return the raw capture, which the route still labels
mux-visible, so a size that moved during such a load bought a full forced
reload to repair a frame that was never positioned. It now tests
Number.isFinite(data.captureRows) like its two siblings.

Plus the invariants and CLAUDE.md lines promised on #435: a visible capture
reports its geometry and omits it when nothing was positioned, the comparison
runs on mux-visible only, and the replay is capped at one attempt and latches
per session when it cannot converge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-19 12:18:39 +02:00
parent 2c3ccdf030
commit c376534a50
7 changed files with 134 additions and 64 deletions
@@ -4,44 +4,11 @@
fix(terminal): replay a pane capture at the geometry it was taken at
A visible-frame capture repaints each row at an absolute position, counting up
to the pane's height and out to the pane's width. A terminal shorter than that
clamps every address past its own height onto its last line, so the overflow
rows overwrite one another and the rows underneath are lost. Against a 50-row
pane, a 30-row terminal rendered 28 of a 45-line command and drew the surviving
frame twice. A narrower terminal damages the same frame a second way: each row
is painted out to the pane's own width, so the browser wraps every painted row,
and the wrap on the last one scrolls the whole frame up by a row.
Nothing in the response said what geometry the frame was built for, so the
client could not detect either case. A capture now reports the geometry it was
really taken at through `capturedGeometry` on `PaneCaptureOptions`, and the
terminal response carries it as `captureCols` and `captureRows`. Both fields are
absent unless the response really carries a capture, since a body that was never
positioned has no geometry to describe. When a captured pane is taller or wider
than the terminal, or the size that produced the capture did not survive the
load, `selectSession` replays once at the size that stuck.
That comparison runs on a visible-frame response only. A full-history response
is linear scrollback closed by a relative cursor move, and a byte-history
response carries no row alignment at all, so a size mismatch damages neither and
a replay repairs neither. The distinction matters because the first load of
every non-shell session per page takes the full-history path, where a replay
would capture the whole tmux scrollback a second time.
Two guards keep the replay to the one pass that can converge. `resizeRetry` caps
it at a single attempt, so two competing fits cannot trade replays forever. A
pane already drawing at the size the client just requested is left alone, which
is the signature of a clamp rather than a race: `getTerminalDimensions()` floors
at 40x10 while `fitAddon.fit()` does not, so a terminal narrower than 40 columns
or shorter than 10 rows reports a pane permanently bigger than itself and would
otherwise replay on every tab switch without ever converging.
One case is still reported rather than repaired. A pane can be too tall because
`Session.resize` declined the resize outright, which it does for a small
viewport while a desktop viewport's size claim is live. The retry re-sends the
same declined resize and captures the same pane, so it costs the one capped
attempt and the frame is shown as it is. Repairing it means deciding who owns
the pane size while a desktop claim is live, which is a policy question this
does not touch. The reported geometry still helps, because the client can see
the mismatch at all rather than being blind to it.
Opening a session could draw a frame built for a pane bigger than your terminal. A
taller pane wrote its overflow rows onto the last line and lost the rows underneath
(against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew
the survivors twice), and a wider one wrapped every row and scrolled the whole frame
up by one. The terminal response now reports the geometry the capture was really
taken at, so the browser can see the mismatch and replay once at the size that stuck.
A pane that cannot be sized to fit is diagnosed once per session instead of on every
tab switch.
@@ -0,0 +1,11 @@
---
"aicodeman": patch
---
fix(run): make the Instance count stepper work for every non-Claude mode
The Instance count stepper next to the Run button only ever applied to Claude.
Setting it to 3 and launching OpenCode, Codex, Gemini, Antigravity, Pi, OMP, Grok or
DeepSeek started exactly one session, with no error and no hint that the control had
done nothing. All eight now launch the count you asked for, and the opening banner
says how many are starting.
+1 -1
View File
@@ -257,7 +257,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. ⚠️ **A visible capture now REPORTS the geometry it was taken at** (`captureCols`/`captureRows`, #435), because a frame built for a pane taller or wider than the browser is damaged two ways at once (overflow rows clamp onto the last line; a narrower browser wraps every painted row) and nothing in the response used to say so. Both fields are ABSENT when no frame was positioned, so every consumer tests `Number.isFinite`, never truthiness: a `display-message` cursor query that fails makes `capturePaneBuffer` return the raw capture while the route still labels it `mux-visible`. The comparison runs on `mux-visible` ONLY, the replay is capped at one attempt, and a pane that cannot be sized to fit latches in `_geometryRetryUseless` so it is diagnosed once per session rather than on every tab switch. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
File diff suppressed because one or more lines are too long
+9
View File
@@ -6577,8 +6577,17 @@ class CodemanApp {
// of every non-shell session per page takes the full-history path, an
// ungated comparison fires most often on the one response it cannot help.
const framePositionsRowsAbsolutely = data.source === 'mux-visible';
// `mux-visible` is necessary but not sufficient: when the `display-message`
// cursor query fails, `capturePaneBuffer` skips the snapshot repaint and
// returns the raw capture, and the route still labels a non-empty body
// `mux-visible`. That body positions nothing and reports no geometry, so a
// size that moved during such a load has nothing to repair, and replaying
// would buy a second capture, a reset plus chunked rewrite, a dropped
// WebSocket and a discarded xterm snapshot for it. The two comparisons
// below already stand down on an absent field; this one has to as well.
const sizeMovedUnderLoad =
framePositionsRowsAbsolutely &&
Number.isFinite(data.captureRows) &&
!!dimsAtCapture &&
!!dimsAfterLoad &&
(dimsAfterLoad.cols !== dimsAtCapture.cols || dimsAfterLoad.rows !== dimsAtCapture.rows);
+40 -22
View File
@@ -930,7 +930,7 @@ Object.assign(CodemanApp.prototype, {
async runClaude() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
@@ -1257,9 +1257,14 @@ Object.assign(CodemanApp.prototype, {
}
},
/** Reads the "Instance count" stepper, clamped like runClaude()'s own copy. */
/**
* Reads the "Instance count" stepper, clamped to 1..20. Single source for every
* run*(), runClaude() included. Optional-chained because callers read it BEFORE
* their try block, to put the count in the opening banner: `#tabCount` ships
* unconditionally today, but a throw here would escape the launch-error path.
*/
_readTabCount() {
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1));
},
/**
@@ -1274,9 +1279,6 @@ Object.assign(CodemanApp.prototype, {
async _launchQuickStartInstances(caseName, tabCount, label, buildBody, ownsLaunchTerminal) {
const startNumber = this._nextCaseSessionStartNumber(caseName);
let firstSessionId = null;
if (tabCount > 1) {
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} ${label} session(s) in ${caseName}...`);
}
for (let i = 0; i < tabCount; i++) {
const sessionName = `w${startNumber + i}-${caseName}`;
const res = await fetch('/api/quick-start', {
@@ -1302,7 +1304,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} OpenCode session(s) in ${caseName}...`
);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
@@ -1323,7 +1328,6 @@ Object.assign(CodemanApp.prototype, {
// Quick-start with opencode mode (auto-allow tools by default).
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1359,7 +1363,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Codex session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1377,7 +1384,6 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1417,7 +1423,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Gemini session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1434,7 +1443,6 @@ Object.assign(CodemanApp.prototype, {
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1468,7 +1476,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Antigravity session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1485,7 +1496,6 @@ Object.assign(CodemanApp.prototype, {
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1528,7 +1538,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Pi session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1545,7 +1558,6 @@ Object.assign(CodemanApp.prototype, {
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1576,7 +1588,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} OMP session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1593,7 +1608,6 @@ Object.assign(CodemanApp.prototype, {
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1635,7 +1649,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Grok session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Grok session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1652,7 +1669,6 @@ Object.assign(CodemanApp.prototype, {
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
@@ -1704,7 +1720,10 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`);
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} DeepSeek session(s) in ${caseName}...`
);
this.terminal.focus();
try {
@@ -1730,7 +1749,6 @@ Object.assign(CodemanApp.prototype, {
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const tabCount = this._readTabCount();
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
+63
View File
@@ -1122,4 +1122,67 @@ describe('Grok quick start', () => {
expect(requests).toEqual(['/api/grok/status']);
expect(errors[0]).toContain('https://x.ai/cli/install.sh');
});
// The whole point of the shared _launchQuickStartInstances() helper. Before it,
// every non-Claude run*() hardcoded exactly one quick-start call, so the
// "Instance count" stepper next to the Run button silently did nothing on all
// eight of them: no error, no hint, just the wrong number of sessions. The five
// fixture edits that came with the change stub tabCount at '1', so they pass
// identically with and without it; this is the one that does not.
it('launches tabCount sessions with sequential w<n>-<case> names and selects the first', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'grok-case' },
tabCount: { value: '3' },
};
const requests: Array<{ url: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
let created = 0;
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 });
if (url === '/api/grok/status')
return {
json: async () => ({
success: true,
data: { available: true, path: '/home/user/.grok/bin', version: '1.0.5' },
}),
};
if (url === '/api/quick-start') {
const id = `sess-gk-${created++}`;
return {
json: async () => ({ success: true, data: { sessionId: id, session: { id, name: body.sessionName } } }),
};
}
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.sessions = new Map();
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
app._renderSessionTabsImmediate = () => {};
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
};
await app.runGrok();
const names = requests.filter((r) => r.url === '/api/quick-start').map((r) => r.body.sessionName);
expect(names).toEqual(['w1-grok-case', 'w2-grok-case', 'w3-grok-case']);
expect(selected).toEqual(['sess-gk-0']);
});
});