From cbb1435a468792a2df01010608e95806a4727138 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 15 Sep 2026 00:50:09 +0200 Subject: [PATCH] refactor(toolbar): one instance stepper, not two The desktop toolbar carried two identical "minus 1 plus" instance steppers side by side, one after Run and one after Run Shell. The second (#shellCount) is gone for a cleaner strip. Run Shell keeps the capability: both launch paths now read the remaining #tabCount control through _toolbarInstanceCount(), which also makes an absent stepper read as 1 instead of throwing. That matters because the group is display:none on phones and tablets, and because the Run dropdown's Terminal / Shell entry routes through runShell() too, where the visible counter was previously ignored. Desktop only: both steppers were already hidden under 1024px. Co-Authored-By: Claude Opus 5 (1M context) --- docs/wiki/The-Dashboard.md | 2 +- test/run-mode-ui.test.ts | 178 ++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index d5130869..7a7c429d 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -15,7 +15,7 @@ page says so and names the setting. | **Header, left** | The "C" logo (goes home) and the session list, unless you moved it to the sidebar. | | **Header, right** | Status chips and panel buttons, most of them off by default. | | **Center** | The terminal for the active session, or the home screen when nothing is selected. | -| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counters. | +| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counter. | | **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. | ## Session list layout diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 731cfdb2..71128ad1 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -691,7 +691,9 @@ describe('case selector refresh', () => { it('creates remote shell sessions by caseName instead of remote display path', async () => { const elements: Record = { quickStartCase: { value: 'gpu-work' }, - shellCount: { value: '1' }, + // The toolbar's one instance stepper, shared by Run and Run Shell since + // the second (#shellCount) group was removed. + tabCount: { value: '1' }, }; const requests: Array<{ url: string; body?: any }> = []; const CodemanApp = function CodemanApp(this: any) {}; @@ -828,6 +830,180 @@ describe('case selector refresh', () => { }); }); +describe('mobile case picker search', () => { + // The phone bottom sheet listed every case with no way to narrow it, while the + // desktop toolbar combobox has filtered for a while. Both now run the same + // matcher (filterCasePickerOptions), so these assert the sheet's own wiring: + // the reset-on-open, the rendered rows, the empty state and the Enter shortcut. + function loadMobilePicker(cases: any[], selected = 'testcase') { + const elements: Record = {}; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + console, + escapeHtml: (value: string) => value, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const classes = new Set(); + elements.mobileCasePickerModal = { + classList: { add: (c: string) => classes.add(c), remove: (c: string) => classes.delete(c) }, + }; + elements.mobileCaseList = { innerHTML: '' }; + elements.mobileCaseSearch = { value: '', focus: () => {} }; + elements.mobileCaseSearchClear = { hidden: true }; + elements.quickStartCase = { value: selected }; + + const app = new (CodemanApp as any)(); + app.cases = cases; + app.updateDirDisplayForCase = () => {}; + app.updateMobileCaseLabel = () => {}; + app.saveLastUsedCase = () => {}; + app.showToast = () => {}; + return { app, elements, classes }; + } + + const renderedNames = (elements: Record) => + [...String(elements.mobileCaseList.innerHTML).matchAll(/mobile-case-item-name">([^<]*) m[1]); + + const cases = [ + { name: 'alpha-api' }, + { name: 'claudeman' }, + { name: 'claudeman-docs' }, + { name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/x/moneytrove' } }, + ]; + + it('lists every case on open and leaves the search field empty', () => { + const { app, elements, classes } = loadMobilePicker(cases); + elements.mobileCaseSearch.value = 'stale query'; + app._mobileCaseFilter = 'stale query'; + + app.showMobileCasePicker(); + + expect(classes.has('active')).toBe(true); + expect(elements.mobileCaseSearch.value).toBe(''); + expect(elements.mobileCaseSearchClear.hidden).toBe(true); + // testcase is synthesized by buildCasePickerOptions when absent. + expect(renderedNames(elements)).toEqual([ + 'alpha-api', + 'claudeman', + 'claudeman-docs', + 'moneytrove @ mac-mini', + 'testcase', + ]); + }); + + it('narrows the rendered rows to the query and reveals the clear button', () => { + const { app, elements } = loadMobilePicker(cases); + app.showMobileCasePicker(); + + elements.mobileCaseSearch.value = 'claud'; + app.filterMobileCaseList(); + + expect(renderedNames(elements)).toEqual(['claudeman', 'claudeman-docs']); + expect(elements.mobileCaseSearchClear.hidden).toBe(false); + + // Same searchText the desktop combobox indexes, so a remote host matches too. + elements.mobileCaseSearch.value = 'mac-mini'; + app.filterMobileCaseList(); + expect(renderedNames(elements)).toEqual(['moneytrove @ mac-mini']); + }); + + it('renders an empty state rather than a blank sheet when nothing matches', () => { + const { app, elements } = loadMobilePicker(cases); + app.showMobileCasePicker(); + + elements.mobileCaseSearch.value = 'nothing-here'; + app.filterMobileCaseList(); + + expect(renderedNames(elements)).toEqual([]); + expect(elements.mobileCaseList.innerHTML).toContain('No cases match'); + }); + + it('clears the filter back to the full list', () => { + const { app, elements } = loadMobilePicker(cases); + app.showMobileCasePicker(); + elements.mobileCaseSearch.value = 'claud'; + app.filterMobileCaseList(); + + app.clearMobileCaseSearch(); + + expect(elements.mobileCaseSearch.value).toBe(''); + expect(elements.mobileCaseSearchClear.hidden).toBe(true); + expect(renderedNames(elements)).toHaveLength(5); + }); + + it('takes a single remaining match on Enter and leaves an ambiguous one alone', () => { + const { app, elements, classes } = loadMobilePicker(cases); + app.showMobileCasePicker(); + + // Two matches: Enter only dismisses the keyboard. + elements.mobileCaseSearch.value = 'claud'; + app.filterMobileCaseList(); + let blurred = false; + app.handleMobileCaseSearchKeydown({ + key: 'Enter', + preventDefault: () => {}, + stopPropagation: () => {}, + target: { + blur: () => { + blurred = true; + }, + }, + }); + expect(blurred).toBe(true); + expect(classes.has('active')).toBe(true); + expect(elements.quickStartCase.value).toBe('testcase'); + + // One match: Enter picks it and closes the sheet. + elements.mobileCaseSearch.value = 'claudeman-d'; + app.filterMobileCaseList(); + app.handleMobileCaseSearchKeydown({ + key: 'Enter', + preventDefault: () => {}, + stopPropagation: () => {}, + target: { blur: () => {} }, + }); + expect(elements.quickStartCase.value).toBe('claudeman-docs'); + expect(classes.has('active')).toBe(false); + }); +}); + +describe('toolbar instance count', () => { + // Run Shell used to carry its own `#shellCount` stepper next to the Run one. + // It was removed, so both launch paths read #tabCount, and an absent stepper + // (phones and tablets hide the group) has to read as 1, not throw. + function loadCounter(elements: Record) { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + console, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + return new (CodemanApp as any)(); + } + + it('reads the shared stepper and falls back to 1 when it is absent', () => { + expect(loadCounter({ tabCount: { value: '3' } })._toolbarInstanceCount()).toBe(3); + expect(loadCounter({})._toolbarInstanceCount()).toBe(1); + expect(loadCounter({ tabCount: { value: '' } })._toolbarInstanceCount()).toBe(1); + expect(loadCounter({ tabCount: { value: '0' } })._toolbarInstanceCount()).toBe(1); + expect(loadCounter({ tabCount: { value: '99' } })._toolbarInstanceCount()).toBe(20); + }); + + it('no longer exposes the removed shell stepper handlers', () => { + const app = loadCounter({ tabCount: { value: '1' } }); + expect(app.incrementShellCount).toBeUndefined(); + expect(app.decrementShellCount).toBeUndefined(); + }); +}); + describe('Gemini quick start', () => { // Regression guard for the ApiResponse-envelope unwrap in runGemini(): the // status check must read `.data.available` and the quick-start response must