From 5abcae16b44c071d97e367444409e0103f572996 Mon Sep 17 00:00:00 2001 From: codeman-local Date: Tue, 21 Jul 2026 00:20:34 +0800 Subject: [PATCH] fix(ui): show new run tabs immediately --- CLAUDE.md | 2 + src/web/public/session-ui.js | 66 +++++++++++++++++++--- test/run-mode-ui.test.ts | 105 +++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c556b3c9..c5ef7a70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,6 +176,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). +**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`. + **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. **Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index fa6af8ee..a3e01b71 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -350,19 +350,60 @@ Object.assign(CodemanApp.prototype, { 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) */ async run() { - const mode = this._runMode || 'claude'; - if (mode === 'opencode') { - return this.runOpenCode(); - } - if (mode === 'codex') { - return this.runCodex(); + if (this._runInFlight) return; + + 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 === 'gemini') { - return this.runGemini(); + + 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'); + } } - return this.runClaude(); }, // 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'); + 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`); @@ -659,6 +701,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); sessionIds.push(result.data.session.id); } firstSessionId = sessionIds[0]; @@ -774,6 +817,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); 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); } if (remoteIds[0]) { @@ -807,6 +851,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); sessionIds.push(result.data.session.id); } @@ -884,6 +929,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); 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 // early-returns when IDs match, skipping buffer load and sendResize) @@ -940,6 +986,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); 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 // early-returns when IDs match, skipping buffer load and sendResize) @@ -992,6 +1039,7 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); 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) { await this.selectSession(data.data.sessionId); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index c778300a..0c5b25af 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -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((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', () => { it('renders Codex CLI settings in a dedicated app settings tab', () => { 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 }); 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/sessions/sess-1') + return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) }; throw new Error(`unexpected fetch: ${url}`); }, console, @@ -128,6 +225,9 @@ describe('Codex quick start settings', () => { }); app.getCaseSettings = () => ({}); app.buildEnvOverrides = () => ({}); + app.sessions = new Map(); + app._onSessionCreated = (session: any) => app.sessions.set(session.id, session); + app._renderSessionTabsImmediate = vi.fn(); const selected: string[] = []; app.selectSession = async (id: string) => { 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/quick-start') 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}`); }, console, @@ -437,6 +539,9 @@ describe('Gemini quick start', () => { app.loadAppSettingsFromStorage = () => ({}); app.getCaseSettings = () => ({}); app.buildEnvOverrides = () => ({}); + app.sessions = new Map(); + app._onSessionCreated = (session: any) => app.sessions.set(session.id, session); + app._renderSessionTabsImmediate = vi.fn(); const selected: string[] = []; app.selectSession = async (id: string) => { selected.push(id);