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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-15 00:50:09 +02:00
parent 8e4606c57b
commit cbb1435a46
2 changed files with 178 additions and 2 deletions
+1 -1
View File
@@ -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
+177 -1
View File
@@ -691,7 +691,9 @@ describe('case selector refresh', () => {
it('creates remote shell sessions by caseName instead of remote display path', async () => {
const elements: Record<string, any> = {
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<string, any> = {};
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<string>();
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, any>) =>
[...String(elements.mobileCaseList.innerHTML).matchAll(/mobile-case-item-name">([^<]*)</g)].map((m) => 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<string, any>) {
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