mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
@@ -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, 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. |
|
| **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. |
|
| **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. |
|
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
|
||||||
|
|
||||||
## Session list layout
|
## Session list layout
|
||||||
|
|||||||
+177
-1
@@ -691,7 +691,9 @@ describe('case selector refresh', () => {
|
|||||||
it('creates remote shell sessions by caseName instead of remote display path', async () => {
|
it('creates remote shell sessions by caseName instead of remote display path', async () => {
|
||||||
const elements: Record<string, any> = {
|
const elements: Record<string, any> = {
|
||||||
quickStartCase: { value: 'gpu-work' },
|
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 requests: Array<{ url: string; body?: any }> = [];
|
||||||
const CodemanApp = function CodemanApp(this: 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', () => {
|
describe('Gemini quick start', () => {
|
||||||
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
|
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
|
||||||
// status check must read `.data.available` and the quick-start response must
|
// status check must read `.data.available` and the quick-start response must
|
||||||
|
|||||||
Reference in New Issue
Block a user