mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(ui): show new run tabs immediately
This commit is contained in:
@@ -350,19 +350,60 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this.run();
|
return this.run();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Ensure a newly-created session is visible without waiting for the SSE event.
|
||||||
|
* The POST response and session:created can arrive in either order, so the
|
||||||
|
* normal idempotent SSE handler remains the single state-upsert path. */
|
||||||
|
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
let session = sessionSnapshot;
|
||||||
|
if (!session && !this.sessions?.has(sessionId)) {
|
||||||
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Failed to load the new session');
|
||||||
|
session = data.data?.session || data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session?.id) this._onSessionCreated(session);
|
||||||
|
// session:created normally uses the debounced renderer. The direct POST path
|
||||||
|
// needs the tab in the DOM before selectSession() marks it active.
|
||||||
|
this._renderSessionTabsImmediate?.();
|
||||||
|
},
|
||||||
|
|
||||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
||||||
async run() {
|
async run() {
|
||||||
const mode = this._runMode || 'claude';
|
if (this._runInFlight) return;
|
||||||
if (mode === 'opencode') {
|
|
||||||
return this.runOpenCode();
|
const startedAt = Date.now();
|
||||||
|
const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500;
|
||||||
|
const runBtn = document.getElementById('runBtn');
|
||||||
|
this._runInFlight = true;
|
||||||
|
if (runBtn) {
|
||||||
|
runBtn.disabled = true;
|
||||||
|
runBtn.setAttribute('aria-busy', 'true');
|
||||||
}
|
}
|
||||||
if (mode === 'codex') {
|
|
||||||
return this.runCodex();
|
try {
|
||||||
|
const mode = this._runMode || 'claude';
|
||||||
|
if (mode === 'opencode') {
|
||||||
|
return await this.runOpenCode();
|
||||||
|
}
|
||||||
|
if (mode === 'codex') {
|
||||||
|
return await this.runCodex();
|
||||||
|
}
|
||||||
|
if (mode === 'gemini') {
|
||||||
|
return await this.runGemini();
|
||||||
|
}
|
||||||
|
return await this.runClaude();
|
||||||
|
} finally {
|
||||||
|
const remaining = minLockMs - (Date.now() - startedAt);
|
||||||
|
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||||
|
this._runInFlight = false;
|
||||||
|
if (runBtn) {
|
||||||
|
runBtn.disabled = false;
|
||||||
|
runBtn.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mode === 'gemini') {
|
|
||||||
return this.runGemini();
|
|
||||||
}
|
|
||||||
return this.runClaude();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
|
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
|
||||||
@@ -596,6 +637,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude 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.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||||
@@ -659,6 +701,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const sessionIds = [];
|
const sessionIds = [];
|
||||||
for (const result of createResults) {
|
for (const result of createResults) {
|
||||||
if (!result.success) throw new Error(result.error);
|
if (!result.success) throw new Error(result.error);
|
||||||
|
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||||
sessionIds.push(result.data.session.id);
|
sessionIds.push(result.data.session.id);
|
||||||
}
|
}
|
||||||
firstSessionId = sessionIds[0];
|
firstSessionId = sessionIds[0];
|
||||||
@@ -774,6 +817,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||||
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||||
remoteIds.push(data.data.sessionId);
|
remoteIds.push(data.data.sessionId);
|
||||||
}
|
}
|
||||||
if (remoteIds[0]) {
|
if (remoteIds[0]) {
|
||||||
@@ -807,6 +851,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const sessionIds = [];
|
const sessionIds = [];
|
||||||
for (const result of createResults) {
|
for (const result of createResults) {
|
||||||
if (!result.success) throw new Error(result.error);
|
if (!result.success) throw new Error(result.error);
|
||||||
|
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||||
sessionIds.push(result.data.session.id);
|
sessionIds.push(result.data.session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +929,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
|
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
|
||||||
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||||
|
|
||||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||||
@@ -940,6 +986,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
|
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
|
||||||
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||||
|
|
||||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||||
@@ -992,6 +1039,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
|
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
|
||||||
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||||
|
|
||||||
if (data.data.sessionId) {
|
if (data.data.sessionId) {
|
||||||
await this.selectSession(data.data.sessionId);
|
await this.selectSession(data.data.sessionId);
|
||||||
|
|||||||
@@ -73,6 +73,101 @@ describe('run mode UI', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Run launch synchronization', () => {
|
||||||
|
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
||||||
|
const runBtn = {
|
||||||
|
disabled: false,
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
removeAttribute: vi.fn(),
|
||||||
|
};
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: (id: string) => (id === 'runBtn' ? runBtn : 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._runMinLockMs = 0;
|
||||||
|
let finishRun!: () => void;
|
||||||
|
app.runClaude = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolveRun) => {
|
||||||
|
finishRun = resolveRun;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = app.run();
|
||||||
|
const duplicate = app.run();
|
||||||
|
|
||||||
|
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runBtn.disabled).toBe(true);
|
||||||
|
expect(runBtn.setAttribute).toHaveBeenCalledWith('aria-busy', 'true');
|
||||||
|
|
||||||
|
finishRun();
|
||||||
|
await Promise.all([first, duplicate]);
|
||||||
|
|
||||||
|
expect(runBtn.disabled).toBe(false);
|
||||||
|
expect(runBtn.removeAttribute).toHaveBeenCalledWith('aria-busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a POST response session immediately without waiting for SSE', async () => {
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: () => null },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
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.sessions = new Map();
|
||||||
|
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
|
||||||
|
app._renderSessionTabsImmediate = vi.fn();
|
||||||
|
const snapshot = { id: 'sess-new', name: 'w1-case', workingDir: '/tmp/case' };
|
||||||
|
|
||||||
|
await app._ensureCreatedSessionVisible(snapshot.id, snapshot);
|
||||||
|
|
||||||
|
expect(context.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
|
||||||
|
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the new session when a quick-start response wins the race with SSE', async () => {
|
||||||
|
const snapshot = { id: 'sess-race', name: 'w1-remote', workingDir: '/remote/work' };
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
json: async () => ({ success: true, data: snapshot }),
|
||||||
|
}));
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: () => null },
|
||||||
|
fetch: fetchMock,
|
||||||
|
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.sessions = new Map();
|
||||||
|
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
|
||||||
|
app._renderSessionTabsImmediate = vi.fn();
|
||||||
|
|
||||||
|
await app._ensureCreatedSessionVisible(snapshot.id);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/sess-race');
|
||||||
|
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
|
||||||
|
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Codex quick start settings', () => {
|
describe('Codex quick start settings', () => {
|
||||||
it('renders Codex CLI settings in a dedicated app settings tab', () => {
|
it('renders Codex CLI settings in a dedicated app settings tab', () => {
|
||||||
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||||
@@ -113,6 +208,8 @@ describe('Codex quick start settings', () => {
|
|||||||
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
||||||
if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
||||||
if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
|
if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
|
||||||
|
if (url === '/api/sessions/sess-1')
|
||||||
|
return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) };
|
||||||
throw new Error(`unexpected fetch: ${url}`);
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
},
|
},
|
||||||
console,
|
console,
|
||||||
@@ -128,6 +225,9 @@ describe('Codex quick start settings', () => {
|
|||||||
});
|
});
|
||||||
app.getCaseSettings = () => ({});
|
app.getCaseSettings = () => ({});
|
||||||
app.buildEnvOverrides = () => ({});
|
app.buildEnvOverrides = () => ({});
|
||||||
|
app.sessions = new Map();
|
||||||
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
||||||
|
app._renderSessionTabsImmediate = vi.fn();
|
||||||
const selected: string[] = [];
|
const selected: string[] = [];
|
||||||
app.selectSession = async (id: string) => {
|
app.selectSession = async (id: string) => {
|
||||||
selected.push(id);
|
selected.push(id);
|
||||||
@@ -424,6 +524,8 @@ describe('Gemini quick start', () => {
|
|||||||
if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
||||||
if (url === '/api/quick-start')
|
if (url === '/api/quick-start')
|
||||||
return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
|
||||||
|
if (url === '/api/sessions/sess-gm')
|
||||||
|
return { json: async () => ({ success: true, data: { id: 'sess-gm', name: 'w1-gemini-case' } }) };
|
||||||
throw new Error(`unexpected fetch: ${url}`);
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
},
|
},
|
||||||
console,
|
console,
|
||||||
@@ -437,6 +539,9 @@ describe('Gemini quick start', () => {
|
|||||||
app.loadAppSettingsFromStorage = () => ({});
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
app.getCaseSettings = () => ({});
|
app.getCaseSettings = () => ({});
|
||||||
app.buildEnvOverrides = () => ({});
|
app.buildEnvOverrides = () => ({});
|
||||||
|
app.sessions = new Map();
|
||||||
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
||||||
|
app._renderSessionTabsImmediate = vi.fn();
|
||||||
const selected: string[] = [];
|
const selected: string[] = [];
|
||||||
app.selectSession = async (id: string) => {
|
app.selectSession = async (id: string) => {
|
||||||
selected.push(id);
|
selected.push(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user