feat(mobile): search box in the Select Case picker

The phone case picker had no way to narrow a long case list, so finding
one meant scrolling a sheet that showed about six rows at a time.

- A search field filters rows by name (every typed word must match, any
  order, case-insensitive), with a "No matching cases" state. Enter picks
  the case when exactly one row is left; Escape clears, then closes.
- The field is not auto-focused, so opening the picker does not raise the
  keyboard. The list holds its unfiltered height while searching so the
  sheet does not jump, and the input is 16px so iOS Safari does not zoom.
- Layout: the sheet padded the home-indicator inset on top of the footer
  already doing so, leaving a dead band under Create New Case; the sheet
  now grows to 80dvh and the list fills it instead of a separate 50vh cap.
- Opening scrolls the list (its own box, not scrollIntoView) to the
  currently selected case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-24 19:56:23 +02:00
parent d67da5c9d0
commit d55bc28a8e
5 changed files with 116 additions and 3 deletions
+2
View File
@@ -106,6 +106,8 @@
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。', 'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
'Select case': '选择案例', 'Select case': '选择案例',
'Select Case': '选择案例', 'Select Case': '选择案例',
'Search cases': '搜索案例',
'No matching cases': '没有匹配的案例',
'All cases': '全部案例', 'All cases': '全部案例',
'No directory': '未选择目录', 'No directory': '未选择目录',
Run: '运行', Run: '运行',
+5
View File
@@ -3204,7 +3204,12 @@
<h3>Select Case</h3> <h3>Select Case</h3>
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</button> <button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</button>
</div> </div>
<div class="mobile-case-picker-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" id="mobileCaseSearch" placeholder="Search cases" aria-label="Search cases" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" oninput="app.filterMobileCases()" onkeydown="app.onMobileCaseSearchKey(event)">
</div>
<div class="mobile-case-picker-body"> <div class="mobile-case-picker-body">
<div class="mobile-case-empty" id="mobileCaseEmpty" hidden>No matching cases</div>
<div class="mobile-case-list" id="mobileCaseList"> <div class="mobile-case-list" id="mobileCaseList">
<!-- Cases populated by JS --> <!-- Cases populated by JS -->
</div> </div>
+4 -2
View File
@@ -2165,9 +2165,11 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
/* The footer already reserves the home-indicator inset; padding the sheet too
counted it twice and left a dead band under Create New Case. */
.mobile-case-picker-sheet { .mobile-case-picker-sheet {
max-height: 60vh; max-height: 80vh;
padding-bottom: var(--safe-area-bottom); max-height: 80dvh;
animation: slideUp 0.2s ease-out; animation: slideUp 0.2s ease-out;
} }
+55
View File
@@ -4506,6 +4506,7 @@ Object.assign(CodemanApp.prototype, {
const isSelected = c.name === currentCase; const isSelected = c.name === currentCase;
html += ` html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}" <button class="mobile-case-item ${isSelected ? 'selected' : ''}"
data-search="${escapeHtml(`${c.label} ${c.name}`.toLowerCase())}"
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})"> onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon"> <span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -4528,7 +4529,61 @@ Object.assign(CodemanApp.prototype, {
} }
listContainer.innerHTML = html; listContainer.innerHTML = html;
// Every open starts unfiltered. The search box is not focused on purpose:
// that would raise the phone keyboard over a list most opens just tap.
const search = document.getElementById('mobileCaseSearch');
if (search) search.value = '';
listContainer.parentElement.style.minHeight = '';
this.filterMobileCases();
modal.classList.add('active'); modal.classList.add('active');
// Bring the current case into view when the list is longer than the sheet.
// Scroll the list's own box, never scrollIntoView(), which can also scroll
// the document under the fixed header.
const body = listContainer.parentElement;
const selected = listContainer.querySelector('.mobile-case-item.selected');
if (body && selected) {
const top = selected.offsetTop - body.offsetTop;
if (top + selected.offsetHeight > body.scrollTop + body.clientHeight) {
body.scrollTop = top - (body.clientHeight - selected.offsetHeight) / 2;
}
}
},
/** Hide case rows whose name does not contain every word typed in the search box. */
filterMobileCases() {
const search = document.getElementById('mobileCaseSearch');
const words = (search?.value || '').toLowerCase().split(/\s+/).filter(Boolean);
// Hold the list at its unfiltered height while searching, so the sheet (and
// the input under the thumb) does not jump as rows disappear.
const body = document.querySelector('.mobile-case-picker-body');
if (body && words.length && !body.style.minHeight) body.style.minHeight = `${body.offsetHeight}px`;
let shown = 0;
for (const item of document.querySelectorAll('#mobileCaseList .mobile-case-item')) {
const hay = item.dataset.search || '';
const match = words.every((w) => hay.includes(w));
item.hidden = !match;
if (match) shown++;
}
const empty = document.getElementById('mobileCaseEmpty');
if (empty) empty.hidden = shown > 0;
},
/** Enter picks the case when the search narrows the list to exactly one; Escape clears, then closes. */
onMobileCaseSearchKey(event) {
if (event.key === 'Enter') {
event.preventDefault();
const visible = [...document.querySelectorAll('#mobileCaseList .mobile-case-item:not([hidden])')];
if (visible.length === 1) visible[0].click();
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (event.target.value) {
event.target.value = '';
this.filterMobileCases();
} else {
this.closeMobileCasePicker();
}
}
}, },
closeMobileCasePicker() { closeMobileCasePicker() {
+50 -1
View File
@@ -7072,12 +7072,61 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fff; color: #fff;
} }
.mobile-case-picker-search {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 20px 4px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 8px;
color: var(--text-dim);
}
.mobile-case-picker-search:focus-within {
border-color: var(--accent, #22c55e);
}
.mobile-case-picker-search input {
flex: 1;
min-width: 0;
padding: 10px 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
/* 16px keeps iOS Safari from zooming the page on focus */
font-size: 16px;
}
/* The wrapper draws the focus state; the global input focus ring doubled it */
#mobileCaseSearch:focus {
outline: none;
border: none;
box-shadow: none;
}
.mobile-case-empty {
padding: 20px;
text-align: center;
color: var(--text-dim);
font-size: 0.9rem;
}
.mobile-case-empty[hidden],
.mobile-case-item[hidden] {
display: none;
}
.mobile-case-picker-body { .mobile-case-picker-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: 8px 0; padding: 8px 0;
max-height: 50vh; /* Fill the sheet (its max-height is the cap); a separate cap here left the
list shorter than the sheet could show. min-height lets flex shrink it. */
min-height: 0;
} }
.mobile-case-list { .mobile-case-list {