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('/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,