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