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/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; 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