From 8e4606c57bcee04b9a4e757808590aa2b9e5701c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 15 Sep 2026 00:49:56 +0200 Subject: [PATCH 1/2] feat(mobile): search the case picker The phone case sheet listed every case with no way to narrow it, while the desktop toolbar combobox has filtered for a while. The sheet now carries a search field that runs the same matcher (filterCasePickerOptions), so both pickers answer a query identically: every term has to appear in the option's searchText, which already carries the name, the rendered label, the path and the remote/docker fields. Details worth keeping: - The filter resets on every open. The sheet is a one-shot picker, and a leftover query would present a truncated list as the whole one. - No autofocus. Focusing raises the keyboard over a sheet anchored to the bottom of the screen, so the user asks for it. - The sheet is a third position:fixed bottom-anchored surface, so it joins the toolbar and the accessory bar in KeyboardHandler's keyboard lift. iOS does not shrink the layout viewport, so an unlifted sheet would sit behind the keyboard with its own search box out of sight. resetLayout() clears the offset unscoped, or a sheet closed while the keyboard was up would slide in already displaced next time. - Enter takes a single remaining match and otherwise just dismisses the keyboard; Escape drops the filter before it closes the sheet. - No match renders an empty state rather than a blank sheet. - The clear button needs an explicit [hidden] rule: the UA's display:none is specificity (0,0,0) and loses to the button's own display:flex. - The input drops the global input:focus-visible ring, which inside an already bordered row drew a second border a few pixels in. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/i18n.js | 2 + src/web/public/index.html | 35 ++++++++++-- src/web/public/mobile-handlers.js | 20 +++++++ src/web/public/mobile.css | 12 ++++ src/web/public/session-ui.js | 95 ++++++++++++++++++++++++++----- src/web/public/styles.css | 93 ++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 20 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 23605d2f..0395dc29 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -102,6 +102,8 @@ 'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。', 'Select case': '选择案例', 'Select Case': '选择案例', + 'Search cases': '搜索案例', + 'No cases match': '没有匹配的案例', 'All cases': '全部案例', 'No directory': '未选择目录', Run: '运行', diff --git a/src/web/public/index.html b/src/web/public/index.html index 39e956f1..fe9d5e2f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -692,11 +692,9 @@

Resume Conversation

-
- - - -
+
Manage

Select Case

+ +
diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 7d815f70..deddaad2 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -410,6 +410,12 @@ const KeyboardHandler = { const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight); const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + // The mobile case picker is a third position:fixed bottom-anchored + // surface, and since it gained a search field the keyboard can open over + // it. iOS does not shrink the layout viewport, so an unlifted sheet sits + // BEHIND the keyboard with its own search box out of sight. + const caseSheet = document.querySelector('.mobile-case-picker.active .mobile-case-picker-sheet'); + if (isSmallMedium) { // Phones/small tablets: toolbar and accessory bar are position:fixed // via CSS. Use translateY to lift them above the keyboard. @@ -426,6 +432,9 @@ const KeyboardHandler = { if (accessoryBar) { accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; } + if (caseSheet) { + caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; + } if (main && keyboardHeight > 0) { const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0; main.style.paddingBottom = `${84 + cjkInputHeight}px`; @@ -436,6 +445,9 @@ const KeyboardHandler = { if (accessoryBar) { accessoryBar.style.bottom = `${keyboardHeight}px`; } + if (caseSheet) { + caseSheet.style.bottom = `${keyboardHeight}px`; + } } // CJK textarea positioning (always position:fixed on touch devices). @@ -464,6 +476,10 @@ const KeyboardHandler = { const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const cjkInput = document.getElementById('cjkInput'); const main = document.querySelector('.main'); + // Not scoped to `.active`, unlike the lift above: a sheet closed while the + // keyboard was still up must still have its inline offset cleared, or the + // next open slides in already displaced. + const caseSheet = document.querySelector('.mobile-case-picker-sheet'); if (toolbar) { toolbar.style.transform = ''; @@ -476,6 +492,10 @@ const KeyboardHandler = { cjkInput.style.transform = ''; cjkInput.style.bottom = ''; } + if (caseSheet) { + caseSheet.style.transform = ''; + caseSheet.style.bottom = ''; + } if (main) { main.style.paddingBottom = ''; } diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9be29153..4e656e07 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2164,6 +2164,18 @@ html.mobile-init .file-browser-panel { font-size: 1.5rem; } + /* With the keyboard up the sheet is lifted above it (mobile-handlers.js), so + what is left to fit is much shorter than 60vh of the layout viewport. Cap + the list rather than the sheet, so the search row and the Create button + stay on screen and only the rows scroll. */ + .keyboard-visible .mobile-case-picker-sheet { + max-height: 45vh; + } + + .keyboard-visible .mobile-case-picker-body { + max-height: 28vh; + } + .mobile-case-picker-footer { padding-bottom: calc(12px + var(--safe-area-bottom)); } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 67cbfad4..98010f87 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -869,17 +869,17 @@ Object.assign(CodemanApp.prototype, { input.value = Math.max(1, current - 1); }, - // Shell count stepper functions - incrementShellCount() { - const input = document.getElementById('shellCount'); - const current = parseInt(input.value) || 1; - input.value = Math.min(20, current + 1); - }, - - decrementShellCount() { - const input = document.getElementById('shellCount'); - const current = parseInt(input.value) || 1; - input.value = Math.max(1, current - 1); + /** + * How many sessions the next launch creates, from the toolbar's single + * instance stepper. Run Shell used to carry a second, identical `− 1 +` group + * of its own (`#shellCount`); that one is gone, so both launch paths read + * this control. An absent stepper reads as 1 rather than throwing: the group + * is display:none on phones and tablets, and the vm-based unit tests stub + * only the elements they exercise. + */ + _toolbarInstanceCount() { + const raw = document.getElementById('tabCount')?.value; + return Math.min(20, Math.max(1, parseInt(raw, 10) || 1)); }, // Next free index for a case's session tabs (e.g. w1-, @@ -930,7 +930,7 @@ Object.assign(CodemanApp.prototype, { async runClaude() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1)); + const tabCount = this._toolbarInstanceCount(); const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} Claude session(s) in ${caseName}...` @@ -1142,7 +1142,7 @@ Object.assign(CodemanApp.prototype, { async runShell() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); + const shellCount = this._toolbarInstanceCount(); const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${shellCount} Shell session(s) in ${caseName}...`, @@ -3916,13 +3916,42 @@ Object.assign(CodemanApp.prototype, { showMobileCasePicker() { const modal = document.getElementById('mobileCasePickerModal'); + const search = document.getElementById('mobileCaseSearch'); + + // Every open starts unfiltered: the sheet is a one-shot picker, and a query + // left over from last time would present a truncated case list as the whole + // one. Deliberately no autofocus: focusing raises the keyboard over a sheet + // that is anchored to the bottom of the screen, so the user asks for it. + this._mobileCaseFilter = ''; + if (search) search.value = ''; + + this.renderMobileCaseList(); + modal.classList.add('active'); + }, + + /** Re-render the sheet's list for the current search text. */ + renderMobileCaseList() { const listContainer = document.getElementById('mobileCaseList'); const select = document.getElementById('quickStartCase'); + if (!listContainer || !select) return; const currentCase = select.value; + const clearBtn = document.getElementById('mobileCaseSearchClear'); + const filter = this._mobileCaseFilter || ''; + if (clearBtn) clearBtn.hidden = filter.length === 0; + + // Same matcher the desktop combobox uses (every term must appear in the + // option's searchText, which carries the name, label, path and the + // remote/docker fields), so both pickers answer a query identically. + const allCases = this.filterCasePickerOptions(this.getCasePickerOptions(), filter); + + if (allCases.length === 0) { + listContainer.innerHTML = '
No cases match
'; + return; + } + // Build case list HTML let html = ''; - const allCases = this.getCasePickerOptions(); for (const c of allCases) { const isSelected = c.name === currentCase; @@ -3950,7 +3979,43 @@ Object.assign(CodemanApp.prototype, { } listContainer.innerHTML = html; - modal.classList.add('active'); + }, + + /** oninput on the sheet's search field. */ + filterMobileCaseList() { + const search = document.getElementById('mobileCaseSearch'); + this._mobileCaseFilter = search?.value || ''; + this.renderMobileCaseList(); + }, + + clearMobileCaseSearch() { + const search = document.getElementById('mobileCaseSearch'); + if (search) search.value = ''; + this._mobileCaseFilter = ''; + this.renderMobileCaseList(); + search?.focus(); + }, + + handleMobileCaseSearchKeydown(event) { + if (event.key === 'Enter') { + // A search that narrowed to one case is an unambiguous choice, so Enter + // takes it instead of leaving the user to reach past the keyboard for a + // single row. Several matches just dismiss the keyboard. + event.preventDefault(); + const matches = this.filterCasePickerOptions(this.getCasePickerOptions(), this._mobileCaseFilter || ''); + if (matches.length === 1) { + this.selectMobileCase(matches[0].name); + } else { + event.target?.blur?.(); + } + } else if (event.key === 'Escape') { + // Swallowed: the document-level Escape handler closes the whole sheet, and + // the first Escape here means "drop the filter", not "give up on picking". + event.preventDefault(); + event.stopPropagation(); + if (this._mobileCaseFilter) this.clearMobileCaseSearch(); + else this.closeMobileCasePicker(); + } }, closeMobileCasePicker() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 398eb6ce..44ede71d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -6834,6 +6834,99 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #fff; } +/* Search row. Tokens only (no literal dark glass) so the light skins need no + override of their own; the sheet itself is already re-pointed at + var(--floating-bg) up in the skin block. */ +.mobile-case-picker-search { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 20px 4px; + padding: 0 10px; + background: var(--bg-input); + border: 1px solid var(--border-light); + border-radius: 10px; +} + +.mobile-case-picker-search:focus-within { + border-color: var(--accent); +} + +.mobile-case-search-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + flex-shrink: 0; +} + +.mobile-case-search-input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--text); + /* 16px: anything smaller makes iOS Safari zoom the page on focus, which + leaves the sheet scrolled off-centre when the field is blurred again. */ + font-size: 16px; + font-family: inherit; + padding: 11px 0; + outline: none; +} + +.mobile-case-search-input::placeholder { + color: var(--text-dim); +} + +/* The global input:focus-visible rule paints a 1px accent ring, which inside + an already-bordered row draws a second border a few pixels in. The row's + :focus-within border is the focus cue here, so the input drops its own. */ +.mobile-case-search-input:focus-visible { + box-shadow: none; +} + +/* The native affordance sits in a different spot per engine and is absent on + Android, so the sheet ships its own clear button and hides this one. */ +.mobile-case-search-input::-webkit-search-cancel-button, +.mobile-case-search-input::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.mobile-case-search-clear { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-dim); + font-size: 1.2rem; + line-height: 1; + cursor: pointer; +} + +.mobile-case-search-clear:active { + background: var(--bg-hover); + color: var(--text); +} + +/* The UA's [hidden] rule is display:none at specificity (0,0,0) and loses to the + display:flex above, so the button has to hide itself explicitly. */ +.mobile-case-search-clear[hidden] { + display: none; +} + +.mobile-case-empty { + padding: 22px 20px; + text-align: center; + color: var(--text-dim); + font-size: 0.9rem; +} + .mobile-case-picker-body { flex: 1; overflow-y: auto; From cbb1435a468792a2df01010608e95806a4727138 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 15 Sep 2026 00:50:09 +0200 Subject: [PATCH 2/2] 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