feat(cases): refresh the case picker and add search to Manage (#483)

The Run bar's case picker only loaded /api/cases at page load, so folders
deleted or created on disk stayed listed until a reload. It now refetches
on open and every 5 seconds while open, repainting only when the list
changed and falling back to another case if the selected one was removed.

The Manage tab of Add Case gains a search box filtering by name or path.
Reorder arrows are disabled while a filter is active so a swap cannot
involve a hidden case.


Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-09-24 23:08:47 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 77ba41f8da
commit 8841bcc93f
4 changed files with 99 additions and 5 deletions
+1
View File
@@ -3175,6 +3175,7 @@
<h2>Manage</h2>
</div>
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
<div class="case-manage-list" id="caseManageList">
<!-- Populated by JS -->
</div>
+88 -5
View File
@@ -122,6 +122,9 @@ const RUN_MODE_LAUNCH = {
* (`openSessionOptions`), guaranteed to drift from each other the moment a
* ninth CLI landed in one and not the other.
*/
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
const CASE_PICKER_REFRESH_MS = 5000;
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
@@ -246,16 +249,74 @@ Object.assign(CodemanApp.prototype, {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
if (!input || !list) return;
const wasOpen = this._casePickerOpen === true;
this._casePickerOpen = true;
this._casePickerFilter = filter;
this._casePickerActiveIndex = 0;
input.setAttribute('aria-expanded', 'true');
this.renderCasePickerList();
// Every keystroke re-enters here, so only the closed -> open transition
// refreshes and arms the timer; typing must not fire a fetch per key.
if (!wasOpen) this._startCasePickerRefresh();
},
/** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */
_startCasePickerRefresh() {
void this.refreshCasePickerCases();
if (this._casePickerRefreshTimer) return;
this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS);
},
_stopCasePickerRefresh() {
if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer);
this._casePickerRefreshTimer = null;
},
/**
* Lighter than loadQuickStartCases(): that one closes the picker and re-picks a
* selection, which would yank the list away from someone mid-browse. This only
* swaps the data and repaints, and does nothing when the list is unchanged.
*/
async refreshCasePickerCases() {
if (this._casePickerRefreshInFlight) return;
this._casePickerRefreshInFlight = true;
try {
const res = await fetch('/api/cases');
if (!res.ok) return;
const cases = (await res.json()).data;
if (!Array.isArray(cases) || !this._casePickerOpen) return;
const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location]));
if (signature(cases) === signature(this.cases)) return;
this.cases = cases;
const select = document.getElementById('quickStartCase');
if (select) {
const previous = select.value;
this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions());
if (cases.some(c => c.name === previous)) {
select.value = previous;
} else if (cases.length > 0) {
// The selected case was removed on disk: fall back the way the initial
// load does, without saving it as the user's last-used case.
const fallback = cases.find(c => c.name === 'testcase') || cases[0];
select.value = fallback.name;
this.updateDirDisplayForCase(fallback.name);
this.updateMobileCaseLabel(fallback.name);
this.updateCasePickerInput(fallback.name);
}
}
this.renderCasePickerList();
} catch {
// A failed poll leaves the list as it was; the next tick retries.
} finally {
this._casePickerRefreshInFlight = false;
}
},
closeCasePicker() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
this._stopCasePickerRefresh();
this._casePickerOpen = false;
this._casePickerFilter = '';
input?.setAttribute('aria-expanded', 'false');
@@ -4286,6 +4347,11 @@ Object.assign(CodemanApp.prototype, {
// Case Management (reorder + delete)
// ═══════════════════════════════════════════════════════════════
setCaseManageFilter(value) {
this._caseManageFilter = String(value || '');
this.renderCaseManageList();
},
renderCaseManageList() {
const container = document.getElementById('caseManageList');
const cases = this.cases || [];
@@ -4294,6 +4360,18 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Every term must appear in the name or path (same rule as the Run picker's
// filter). Reordering stays on the FULL list, so the arrows are disabled while
// a filter is active: a swap with a neighbour the user cannot see is a surprise.
const terms = (this._caseManageFilter || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
const filtering = terms.length > 0;
const visible = filtering
? cases.filter(c => {
const haystack = `${c.name} ${c.path || ''}`.toLowerCase();
return terms.every(term => haystack.includes(term));
})
: cases;
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
// A long orchestration leaves one scratch directory per worker behind, so they get
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
@@ -4305,9 +4383,14 @@ Object.assign(CodemanApp.prototype, {
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
</div>`
: '';
cases.forEach((c, idx) => {
const isFirst = idx === 0;
const isLast = idx === cases.length - 1;
if (filtering && visible.length === 0) {
html += '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases match</div>';
}
visible.forEach(c => {
const idx = cases.indexOf(c);
const isFirst = filtering || idx === 0;
const isLast = filtering || idx === cases.length - 1;
const reorderTitle = filtering ? 'Clear the search to reorder' : null;
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
// case path on a Linux host rendered in full, unabbreviated.
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
@@ -4331,9 +4414,9 @@ Object.assign(CodemanApp.prototype, {
: ''
}
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>