mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29: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, 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
|
||||||
|
|||||||
@@ -102,6 +102,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 cases match': '没有匹配的案例',
|
||||||
'All cases': '全部案例',
|
'All cases': '全部案例',
|
||||||
'No directory': '未选择目录',
|
'No directory': '未选择目录',
|
||||||
Run: '运行',
|
Run: '运行',
|
||||||
|
|||||||
@@ -692,11 +692,9 @@
|
|||||||
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
||||||
Enter
|
Enter
|
||||||
</button>
|
</button>
|
||||||
<div class="tab-count-group" title="Instance count">
|
<!-- Run Shell had a second, identical instance-count stepper here. The
|
||||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
toolbar carried two of them side by side, so it is gone and Run
|
||||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
Shell reads the one above (#tabCount) like the Run button does. -->
|
||||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
|
||||||
</div>
|
|
||||||
<div class="case-select-group">
|
<div class="case-select-group">
|
||||||
<div class="case-combobox" id="quickStartCasePicker">
|
<div class="case-combobox" id="quickStartCasePicker">
|
||||||
<input
|
<input
|
||||||
@@ -3017,6 +3015,33 @@
|
|||||||
<h3>Select Case</h3>
|
<h3>Select Case</h3>
|
||||||
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
||||||
</div>
|
</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-picker-body">
|
||||||
<div class="mobile-case-list" id="mobileCaseList">
|
<div class="mobile-case-list" id="mobileCaseList">
|
||||||
<!-- Cases populated by JS -->
|
<!-- Cases populated by JS -->
|
||||||
|
|||||||
@@ -410,6 +410,12 @@ const KeyboardHandler = {
|
|||||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
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) {
|
if (isSmallMedium) {
|
||||||
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
||||||
// via CSS. Use translateY to lift them above the keyboard.
|
// via CSS. Use translateY to lift them above the keyboard.
|
||||||
@@ -426,6 +432,9 @@ const KeyboardHandler = {
|
|||||||
if (accessoryBar) {
|
if (accessoryBar) {
|
||||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||||
}
|
}
|
||||||
|
if (caseSheet) {
|
||||||
|
caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||||
|
}
|
||||||
if (main && keyboardHeight > 0) {
|
if (main && keyboardHeight > 0) {
|
||||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||||
@@ -436,6 +445,9 @@ const KeyboardHandler = {
|
|||||||
if (accessoryBar) {
|
if (accessoryBar) {
|
||||||
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
||||||
}
|
}
|
||||||
|
if (caseSheet) {
|
||||||
|
caseSheet.style.bottom = `${keyboardHeight}px`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CJK textarea positioning (always position:fixed on touch devices).
|
// CJK textarea positioning (always position:fixed on touch devices).
|
||||||
@@ -464,6 +476,10 @@ const KeyboardHandler = {
|
|||||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||||
const cjkInput = document.getElementById('cjkInput');
|
const cjkInput = document.getElementById('cjkInput');
|
||||||
const main = document.querySelector('.main');
|
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) {
|
if (toolbar) {
|
||||||
toolbar.style.transform = '';
|
toolbar.style.transform = '';
|
||||||
@@ -476,6 +492,10 @@ const KeyboardHandler = {
|
|||||||
cjkInput.style.transform = '';
|
cjkInput.style.transform = '';
|
||||||
cjkInput.style.bottom = '';
|
cjkInput.style.bottom = '';
|
||||||
}
|
}
|
||||||
|
if (caseSheet) {
|
||||||
|
caseSheet.style.transform = '';
|
||||||
|
caseSheet.style.bottom = '';
|
||||||
|
}
|
||||||
if (main) {
|
if (main) {
|
||||||
main.style.paddingBottom = '';
|
main.style.paddingBottom = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2164,6 +2164,18 @@ html.mobile-init .file-browser-panel {
|
|||||||
font-size: 1.5rem;
|
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 {
|
.mobile-case-picker-footer {
|
||||||
padding-bottom: calc(12px + var(--safe-area-bottom));
|
padding-bottom: calc(12px + var(--safe-area-bottom));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,17 +869,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
input.value = Math.max(1, current - 1);
|
input.value = Math.max(1, current - 1);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Shell count stepper functions
|
/**
|
||||||
incrementShellCount() {
|
* How many sessions the next launch creates, from the toolbar's single
|
||||||
const input = document.getElementById('shellCount');
|
* instance stepper. Run Shell used to carry a second, identical `− 1 +` group
|
||||||
const current = parseInt(input.value) || 1;
|
* of its own (`#shellCount`); that one is gone, so both launch paths read
|
||||||
input.value = Math.min(20, current + 1);
|
* 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.
|
||||||
decrementShellCount() {
|
*/
|
||||||
const input = document.getElementById('shellCount');
|
_toolbarInstanceCount() {
|
||||||
const current = parseInt(input.value) || 1;
|
const raw = document.getElementById('tabCount')?.value;
|
||||||
input.value = Math.max(1, current - 1);
|
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>,
|
// 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() {
|
async runClaude() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
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(
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||||
@@ -1142,7 +1142,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
async runShell() {
|
async runShell() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
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(
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||||
@@ -3916,13 +3916,42 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
showMobileCasePicker() {
|
showMobileCasePicker() {
|
||||||
const modal = document.getElementById('mobileCasePickerModal');
|
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 listContainer = document.getElementById('mobileCaseList');
|
||||||
const select = document.getElementById('quickStartCase');
|
const select = document.getElementById('quickStartCase');
|
||||||
|
if (!listContainer || !select) return;
|
||||||
const currentCase = select.value;
|
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
|
// Build case list HTML
|
||||||
let html = '';
|
let html = '';
|
||||||
const allCases = this.getCasePickerOptions();
|
|
||||||
|
|
||||||
for (const c of allCases) {
|
for (const c of allCases) {
|
||||||
const isSelected = c.name === currentCase;
|
const isSelected = c.name === currentCase;
|
||||||
@@ -3950,7 +3979,43 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listContainer.innerHTML = html;
|
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() {
|
closeMobileCasePicker() {
|
||||||
|
|||||||
@@ -6834,6 +6834,99 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
color: #fff;
|
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 {
|
.mobile-case-picker-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
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 () => {
|
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