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 @@ -
- - - -
+
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;