From 0a039239e4052a63b5b5cdbfd338420aae9d86b6 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 03:30:38 +0300 Subject: [PATCH 1/2] fix(sessions): preserve active terminal during launches --- src/web/public/session-ui.js | 91 ++++++++++++++++++++++++------------ test/run-mode-ui.test.ts | 30 ++++++++++++ 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d3cbec61..95fdd862 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -576,13 +576,43 @@ Object.assign(CodemanApp.prototype, { return startNumber; }, + /** + * Launch progress may use the terminal only on the session-less home screen. + * When another session is active, mutating the shared xterm would serialize + * launch chrome into that session's snapshot during the subsequent switch. + */ + _beginSessionLaunchStatus(message, ansiColor = '1;32') { + const ownsTerminal = !this.activeSessionId; + if (ownsTerminal) { + this.terminal.clear(); + this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`); + this.terminal.writeln(''); + } else { + this.showToast?.(message, 'info'); + } + return ownsTerminal; + }, + + _appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') { + if (!ownsTerminal || this.activeSessionId) return; + this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`); + }, + + _reportSessionLaunchError(ownsTerminal, message) { + if (ownsTerminal && !this.activeSessionId) { + this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`); + } else { + this.showToast?.(message, 'error'); + } + }, + async runClaude() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1)); - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus( + `Starting ${tabCount} Claude session(s) in ${caseName}...` + ); // Focus terminal NOW, in the synchronous user-gesture context (button click). // iOS Safari ignores programmatic focus() after any await, so this must happen // before the first async call. The keyboard opens here and stays open through @@ -665,7 +695,7 @@ Object.assign(CodemanApp.prototype, { await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); remoteIds.push(data.data.sessionId); } - this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); this.loadQuickStartCases(); @@ -700,7 +730,7 @@ Object.assign(CodemanApp.prototype, { const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : ''); // Step 1: Create all sessions in parallel - this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`); const createPromises = sessionNames.map(name => fetch('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/api/sessions', { method: 'POST', @@ -741,12 +771,12 @@ Object.assign(CodemanApp.prototype, { )); // Step 3: Start all sessions in parallel (biggest speedup) - this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`); await Promise.all(sessionIds.map(id => fetch(`/api/sessions/${id}/interactive`, { method: 'POST' }) )); - this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`); // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { @@ -756,7 +786,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -799,9 +829,10 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus( + `Starting ${shellCount} Shell session(s) in ${caseName}...`, + '1;33' + ); try { // Get the case path @@ -907,7 +938,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -918,9 +949,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`); // Focus in sync gesture context (see runClaude comment) this.terminal.focus(); @@ -930,8 +959,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/opencode/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); return; } } @@ -964,7 +995,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -975,9 +1006,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`); this.terminal.focus(); try { @@ -985,8 +1014,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/codex/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'Codex CLI not found. Install with: npm install -g @openai/codex' + ); return; } } @@ -1021,7 +1052,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -1032,9 +1063,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`); this.terminal.focus(); try { @@ -1042,8 +1071,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/gemini/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' + ); return; } } @@ -1072,7 +1103,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 0c5b25af..12efe6b8 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -74,6 +74,36 @@ describe('run mode UI', () => { }); describe('Run launch synchronization', () => { + it('keeps launch progress out of an active session terminal', () => { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + 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.activeSessionId = 'existing-session'; + app.terminal = { + clear: vi.fn(), + writeln: vi.fn(), + }; + app.showToast = vi.fn(); + + const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32'); + app._appendSessionLaunchStatus(ownsTerminal, 'Creating session'); + app._reportSessionLaunchError(ownsTerminal, 'Launch failed'); + + expect(ownsTerminal).toBe(false); + expect(app.terminal.clear).not.toHaveBeenCalled(); + expect(app.terminal.writeln).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info'); + expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error'); + }); + it('coalesces overlapping Run activations and disables the button while the request is active', async () => { const runBtn = { disabled: false, From 292ba2c7756f86748950d7dedb4bd54c05c43b94 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 4 Aug 2026 23:47:17 +0200 Subject: [PATCH 2/2] fix(sessions): route antigravity launches through the ownership helpers runAntigravity() landed on master after this branch was cut, so it kept the exact pattern the rest of this PR removes: terminal.clear() plus direct writeln into whatever session happened to be active. Merging master in surfaced it, leaving one of six run modes still wiping the active session's xterm on launch. Also adds regression coverage that can actually see the bug. The existing test drives the three helpers directly, so it stays green even when a run*() function is reverted to writing at the terminal itself: reverting runClaude()'s call site keeps all 16 tests passing. The new static guard scans session-ui.js and fails if any run*() body touches this.terminal.clear/writeln, which catches a regressed call site and would have caught runAntigravity on its own. A second unit test covers the home-screen path that nothing exercised: with no active session, launch progress must still clear and render in the terminal. Verified in a browser against a live instance. With a session active, runShell() and runAntigravity() leave its terminal untouched (clear() calls: 0, writes: 0) and emit one info toast; on master the same run wipes the session's marker text. The session-less home screen still clears and writes exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/session-ui.js | 12 +++---- test/run-mode-ui.test.ts | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 3cbb2fcb..80d2c13b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1120,9 +1120,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`); this.terminal.focus(); try { @@ -1130,8 +1128,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/antigravity/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' + ); return; } } @@ -1160,7 +1160,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 563d8e09..c698cb86 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -114,6 +114,71 @@ describe('Run launch synchronization', () => { expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error'); }); + it('still renders launch progress in the terminal on the session-less home screen', () => { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + 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.activeSessionId = null; // home screen: nothing else owns the terminal + app.terminal = { clear: vi.fn(), writeln: vi.fn() }; + app.showToast = vi.fn(); + + const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32'); + app._appendSessionLaunchStatus(ownsTerminal, 'Creating session'); + app._reportSessionLaunchError(ownsTerminal, 'Launch failed'); + + expect(ownsTerminal).toBe(true); + expect(app.terminal.clear).toHaveBeenCalledTimes(1); + expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session'); + expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session'); + expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed'); + expect(app.showToast).not.toHaveBeenCalled(); + }); + + /** + * Static guard over session-ui.js itself. The helpers above can be perfectly + * correct while a run*() entry point still writes to the shared xterm + * directly, which is the actual bug: a launch started while another session + * is active wipes that session's terminal, and _cleanupPreviousSession() + * then serializes the wiped view into its restore snapshot. Asserting on the + * helpers alone cannot see that, so pin the call sites here. This also + * covers run modes added later, which is how runAntigravity was caught. + */ + it('routes every run mode through the ownership helpers, never the terminal directly', () => { + const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + + // Methods live in one Object.assign(prototype, {...}) block at a fixed + // 2-space indent, so `\n },` reliably closes the one we are inside. + const bodies = new Map(); + const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm; + for (let m = header.exec(src); m; m = header.exec(src)) { + const start = m.index + m[0].length; + const end = src.indexOf('\n },', start); + expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start); + bodies.set(m[1], src.slice(start, end)); + } + + // Fail loudly if the scan matched nothing: a silently empty scan would make + // every assertion below vacuously true. + expect([...bodies.keys()]).toEqual( + expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity']) + ); + + for (const [name, body] of bodies) { + expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear('); + expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain( + 'this.terminal.writeln(' + ); + } + }); + it('coalesces overlapping Run activations and disables the button while the request is active', async () => { const runBtn = { disabled: false,