From 1ea39de650930b4ba3b554cd2cda573661544730 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 20:01:24 -0500 Subject: [PATCH] fix(mobile): gate the phone overview's run picker on CLI availability MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu is a separate, hardcoded duplicate of the toolbar's #runModeMenu (mobile-overview.js is a newer feature that mirrors the toolbar menu's look/behavior rather than reusing its render), so it never picked up #201's isCliAvailable() gating and offered every backend regardless of what the server actually has installed. Gate it the same way: skip an entry unless isCliAvailable(mode), shell always exempt. Added functional + static regression tests mirroring the toolbar menu's own test pattern. Co-Authored-By: Claude Sonnet 5 --- src/web/public/mobile-overview.js | 6 +++ test/mobile-overview.test.ts | 67 ++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 26927e6a..79a2c0c3 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -486,6 +486,11 @@ Object.assign(CodemanApp.prototype, { * The Run picker: the same backends as the toolbar's run-mode menu, plus saved * web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu: * past conversations have their own section further down this screen. + * + * Gated the same way as the toolbar's #runModeMenu (isCliAvailable(), shell + * exempt) — this list is a separate, hardcoded duplicate of the toolbar's menu + * rather than a shared render, so it never picked up #201's gating and offered + * every backend regardless of what's actually installed. */ _buildMobileOverviewRunMenu() { const menu = document.createElement('div'); @@ -493,6 +498,7 @@ Object.assign(CodemanApp.prototype, { const current = this.runMode || 'claude'; for (const entry of MOBILE_OVERVIEW_RUN_MODES) { + if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue; const option = document.createElement('button'); option.type = 'button'; option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : ''); diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index 14221c99..c1e5d655 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -12,13 +12,34 @@ import { describe, expect, it } from 'vitest'; const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +/** Minimal fake DOM node — enough surface for mobile-overview.js's programmatic builders. */ +function fakeElement(): any { + const el: any = { + className: '', + type: '', + dataset: {}, + style: {}, + children: [] as any[], + setAttribute() {}, + appendChild(child: any) { + el.children.push(child); + return child; + }, + }; + return el; +} + function loadOverviewApp(overrides: Record = {}) { const CodemanApp = function CodemanApp(this: any) {}; const context = vm.createContext({ CodemanApp, console, window: {}, - document: { getElementById: () => null }, + document: { + getElementById: () => null, + createElement: () => fakeElement(), + createElementNS: () => fakeElement(), + }, MobileDetection: { getDeviceType: () => 'mobile' }, }); vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, { @@ -272,3 +293,47 @@ describe('mobile overview wiring', () => { expect(hardcoded).toEqual([]); }); }); + +describe('mobile overview run picker (CLI availability gating)', () => { + function modeButtons(menu: any): string[] { + return menu.children.filter((c: any) => c.dataset.moAction === 'run-mode').map((c: any) => c.dataset.moMode); + } + + // #201 gated the toolbar's #runModeMenu on isCliAvailable(); this phone-only + // picker (MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu) is a + // separate, hardcoded duplicate of that menu rather than a shared render, so + // it silently offered every backend regardless of what the server reported. + it('hides run modes the server reports as unavailable, keeps shell always', () => { + const app = loadOverviewApp({ + runMode: 'claude', + isCliAvailable: (tool: string) => tool === 'claude', + }); + const menu = app._buildMobileOverviewRunMenu(); + expect(modeButtons(menu)).toEqual(['claude', 'shell']); + }); + + it('shows every mode when every CLI is available', () => { + const app = loadOverviewApp({ + runMode: 'claude', + isCliAvailable: () => true, + }); + const menu = app._buildMobileOverviewRunMenu(); + expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']); + }); + + it('gates every mode the picker actually offers', () => { + // Catches a new backend being added to MOBILE_OVERVIEW_RUN_MODES without + // being gated — the same class of bug that let this list drift from the + // toolbar menu's gating in the first place. + const src = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'); + const modesBlock = src.slice( + src.indexOf('const MOBILE_OVERVIEW_RUN_MODES'), + src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2 + ); + const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]); + expect(offered).toContain('antigravity'); + const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {')); + const gate = fn.slice(0, fn.indexOf('const header')); + expect(gate).toContain('isCliAvailable'); + }); +});