From 8841bcc93f03621ed8996705d6dba824fcb410ba Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:08:47 +0800 Subject: [PATCH] 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 --- .changeset/casemanage2.md | 5 ++ .changeset/casepicker1.md | 5 ++ src/web/public/index.html | 1 + src/web/public/session-ui.js | 93 ++++++++++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 .changeset/casemanage2.md create mode 100644 .changeset/casepicker1.md diff --git a/.changeset/casemanage2.md b/.changeset/casemanage2.md new file mode 100644 index 00000000..0b971a67 --- /dev/null +++ b/.changeset/casemanage2.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case. diff --git a/.changeset/casepicker1.md b/.changeset/casepicker1.md new file mode 100644 index 00000000..e03aa06c --- /dev/null +++ b/.changeset/casepicker1.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one. diff --git a/src/web/public/index.html b/src/web/public/index.html index 40eb112f..ba7a254d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3175,6 +3175,7 @@

Manage

Reorder or remove cases, and pick up anything exported from a docker case.

+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 7ee0e02f..6ad55178 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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 ` : ''; - cases.forEach((c, idx) => { - const isFirst = idx === 0; - const isLast = idx === cases.length - 1; + if (filtering && visible.length === 0) { + html += '
No cases match
'; + } + 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/` 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, { : '' } + title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲ + title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼