mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -4,44 +4,11 @@
|
|||||||
|
|
||||||
fix(terminal): replay a pane capture at the geometry it was taken at
|
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
|
Opening a session could draw a frame built for a pane bigger than your terminal. A
|
||||||
to the pane's height and out to the pane's width. A terminal shorter than that
|
taller pane wrote its overflow rows onto the last line and lost the rows underneath
|
||||||
clamps every address past its own height onto its last line, so the overflow
|
(against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew
|
||||||
rows overwrite one another and the rows underneath are lost. Against a 50-row
|
the survivors twice), and a wider one wrapped every row and scrolled the whole frame
|
||||||
pane, a 30-row terminal rendered 28 of a 45-line command and drew the surviving
|
up by one. The terminal response now reports the geometry the capture was really
|
||||||
frame twice. A narrower terminal damages the same frame a second way: each row
|
taken at, so the browser can see the mismatch and replay once at the size that stuck.
|
||||||
is painted out to the pane's own width, so the browser wraps every painted row,
|
A pane that cannot be sized to fit is diagnosed once per session instead of on every
|
||||||
and the wrap on the last one scrolls the whole frame up by a row.
|
tab switch.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
**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`.
|
**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
@@ -6577,8 +6577,17 @@ class CodemanApp {
|
|||||||
// of every non-shell session per page takes the full-history path, an
|
// 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.
|
// ungated comparison fires most often on the one response it cannot help.
|
||||||
const framePositionsRowsAbsolutely = data.source === 'mux-visible';
|
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 =
|
const sizeMovedUnderLoad =
|
||||||
framePositionsRowsAbsolutely &&
|
framePositionsRowsAbsolutely &&
|
||||||
|
Number.isFinite(data.captureRows) &&
|
||||||
!!dimsAtCapture &&
|
!!dimsAtCapture &&
|
||||||
!!dimsAfterLoad &&
|
!!dimsAfterLoad &&
|
||||||
(dimsAfterLoad.cols !== dimsAtCapture.cols || dimsAfterLoad.rows !== dimsAtCapture.rows);
|
(dimsAfterLoad.cols !== dimsAtCapture.cols || dimsAfterLoad.rows !== dimsAtCapture.rows);
|
||||||
|
|||||||
@@ -930,7 +930,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
async runClaude() {
|
async runClaude() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
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(
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
`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() {
|
_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) {
|
async _launchQuickStartInstances(caseName, tabCount, label, buildBody, ownsLaunchTerminal) {
|
||||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||||
let firstSessionId = null;
|
let firstSessionId = null;
|
||||||
if (tabCount > 1) {
|
|
||||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} ${label} session(s) in ${caseName}...`);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < tabCount; i++) {
|
for (let i = 0; i < tabCount; i++) {
|
||||||
const sessionName = `w${startNumber + i}-${caseName}`;
|
const sessionName = `w${startNumber + i}-${caseName}`;
|
||||||
const res = await fetch('/api/quick-start', {
|
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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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)
|
// Focus in sync gesture context (see runClaude comment)
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
|
|
||||||
@@ -1323,7 +1328,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Quick-start with opencode mode (auto-allow tools by default).
|
// Quick-start with opencode mode (auto-allow tools by default).
|
||||||
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1359,7 +1363,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1377,7 +1384,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const globalSettings = this.loadAppSettingsFromStorage();
|
const globalSettings = this.loadAppSettingsFromStorage();
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1417,7 +1423,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1434,7 +1443,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1468,7 +1476,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1485,7 +1496,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1528,7 +1538,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1545,7 +1558,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1576,7 +1588,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1593,7 +1608,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1635,7 +1649,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1652,7 +1669,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
@@ -1704,7 +1720,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
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();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1730,7 +1749,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
|
|||||||
@@ -1122,4 +1122,67 @@ describe('Grok quick start', () => {
|
|||||||
expect(requests).toEqual(['/api/grok/status']);
|
expect(requests).toEqual(['/api/grok/status']);
|
||||||
expect(errors[0]).toContain('https://x.ai/cli/install.sh');
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user