fix(sessions): preserve active terminal during launches

This commit is contained in:
lior
2026-07-29 03:30:38 +03:00
parent 77bcbc9b94
commit 0a039239e4
2 changed files with 91 additions and 30 deletions
+61 -30
View File
@@ -576,13 +576,43 @@ Object.assign(CodemanApp.prototype, {
return startNumber; 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() { 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 = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
this.terminal.clear(); const ownsLaunchTerminal = this._beginSessionLaunchStatus(
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); `Starting ${tabCount} Claude session(s) in ${caseName}...`
this.terminal.writeln(''); );
// Focus terminal NOW, in the synchronous user-gesture context (button click). // Focus terminal NOW, in the synchronous user-gesture context (button click).
// iOS Safari ignores programmatic focus() after any await, so this must happen // 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 // 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); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
remoteIds.push(data.data.sessionId); 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]) { if (remoteIds[0]) {
await this.selectSession(remoteIds[0]); await this.selectSession(remoteIds[0]);
this.loadQuickStartCases(); this.loadQuickStartCases();
@@ -700,7 +730,7 @@ Object.assign(CodemanApp.prototype, {
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : ''); const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
// Step 1: Create all sessions in parallel // 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 => const createPromises = sessionNames.map(name =>
fetch('/api/sessions', { fetch('/api/sessions', {
method: 'POST', method: 'POST',
@@ -741,12 +771,12 @@ Object.assign(CodemanApp.prototype, {
)); ));
// Step 3: Start all sessions in parallel (biggest speedup) // 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 => await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' }) 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) // Auto-switch to the new session using selectSession (does proper refresh)
if (firstSessionId) { if (firstSessionId) {
@@ -756,7 +786,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus(); this.terminal.focus();
} catch (err) { } 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 caseName = document.getElementById('quickStartCase').value || 'testcase';
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
this.terminal.clear(); const ownsLaunchTerminal = this._beginSessionLaunchStatus(
this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`); `Starting ${shellCount} Shell session(s) in ${caseName}...`,
this.terminal.writeln(''); '1;33'
);
try { try {
// Get the case path // Get the case path
@@ -907,7 +938,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus(); this.terminal.focus();
} catch (err) { } 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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear(); const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
// Focus in sync gesture context (see runClaude comment) // Focus in sync gesture context (see runClaude comment)
this.terminal.focus(); this.terminal.focus();
@@ -930,8 +959,10 @@ Object.assign(CodemanApp.prototype, {
const statusRes = await fetch('/api/opencode/status'); const statusRes = await fetch('/api/opencode/status');
const status = (await statusRes.json()).data; const status = (await statusRes.json()).data;
if (!status.available) { if (!status.available) {
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); this._reportSessionLaunchError(
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); ownsLaunchTerminal,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
return; return;
} }
} }
@@ -964,7 +995,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus(); this.terminal.focus();
} catch (err) { } 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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear(); const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
this.terminal.focus(); this.terminal.focus();
try { try {
@@ -985,8 +1014,10 @@ Object.assign(CodemanApp.prototype, {
const statusRes = await fetch('/api/codex/status'); const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data; const status = (await statusRes.json()).data;
if (!status.available) { if (!status.available) {
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m'); this._reportSessionLaunchError(
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m'); ownsLaunchTerminal,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
return; return;
} }
} }
@@ -1021,7 +1052,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus(); this.terminal.focus();
} catch (err) { } 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 _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear(); const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
this.terminal.focus(); this.terminal.focus();
try { try {
@@ -1042,8 +1071,10 @@ Object.assign(CodemanApp.prototype, {
const statusRes = await fetch('/api/gemini/status'); const statusRes = await fetch('/api/gemini/status');
const status = (await statusRes.json()).data; const status = (await statusRes.json()).data;
if (!status.available) { if (!status.available) {
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m'); this._reportSessionLaunchError(
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m'); ownsLaunchTerminal,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
return; return;
} }
} }
@@ -1072,7 +1103,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus(); this.terminal.focus();
} catch (err) { } catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
} }
}, },
+30
View File
@@ -74,6 +74,36 @@ describe('run mode UI', () => {
}); });
describe('Run launch synchronization', () => { 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 () => { it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
const runBtn = { const runBtn = {
disabled: false, disabled: false,