Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/wiki/The-Dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/web/public/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '运行',
Expand Down
35 changes: 30 additions & 5 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -692,11 +692,9 @@ <h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<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
Expand Down Expand Up @@ -3017,6 +3015,33 @@ <h2>Manage</h2>
<h3>Select Case</h3>
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</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>&times;</button>
</div>
<div class="mobile-case-picker-body">
<div class="mobile-case-list" id="mobileCaseList">
<!-- Cases populated by JS -->
Expand Down
20 changes: 20 additions & 0 deletions src/web/public/mobile-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`;
Expand All @@ -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).
Expand Down Expand Up @@ -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 = '';
Expand All @@ -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 = '';
}
Expand Down
12 changes: 12 additions & 0 deletions src/web/public/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
95 changes: 80 additions & 15 deletions src/web/public/session-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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}...`
Expand Down Expand Up @@ -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}...`,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
93 changes: 93 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading