mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb1435a46 | ||
|
|
8e4606c57b |
@@ -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
|
||||
|
||||
@@ -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: '运行',
|
||||
|
||||
@@ -692,11 +692,9 @@
|
||||
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
||||
Enter
|
||||
</button>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||
</div>
|
||||
<!-- Run Shell had a second, identical instance-count stepper here. The
|
||||
toolbar carried two of them side by side, so it is gone and Run
|
||||
Shell reads the one above (#tabCount) like the Run button does. -->
|
||||
<div class="case-select-group">
|
||||
<div class="case-combobox" id="quickStartCasePicker">
|
||||
<input
|
||||
@@ -3017,6 +3015,33 @@
|
||||
<h3>Select Case</h3>
|
||||
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
||||
</div>
|
||||
<!-- Search: the desktop toolbar has had a filtering combobox for a while
|
||||
(#quickStartCaseSearch); this is the same matcher on the phone sheet,
|
||||
where a long case list is otherwise a long scroll. -->
|
||||
<div class="mobile-case-picker-search">
|
||||
<span class="mobile-case-search-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
id="mobileCaseSearch"
|
||||
class="mobile-case-search-input"
|
||||
placeholder="Search cases"
|
||||
aria-label="Search cases"
|
||||
aria-controls="mobileCaseList"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
enterkeyhint="search"
|
||||
oninput="app.filterMobileCaseList()"
|
||||
onkeydown="app.handleMobileCaseSearchKeydown(event)"
|
||||
>
|
||||
<button type="button" class="mobile-case-search-clear" id="mobileCaseSearchClear"
|
||||
onclick="app.clearMobileCaseSearch()" aria-label="Clear search" hidden>×</button>
|
||||
</div>
|
||||
<div class="mobile-case-picker-body">
|
||||
<div class="mobile-case-list" id="mobileCaseList">
|
||||
<!-- Cases populated by 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 = '';
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 <prefix><n> index for a case's session tabs (e.g. w1-<case>,
|
||||
@@ -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 = '<div class="mobile-case-empty">No cases match</div>';
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+177
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user