From c04cba29d9619c73c77e8123d1786250b6ca7d4e Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 08:06:03 +0000 Subject: [PATCH] feat: batch-plan mobile search results into Today (Closes #809) --- frontend/batch-find-work.js | 14 +++- frontend/dashboard.css | 9 +++ frontend/dashboard.js | 12 ++- frontend/index.html | 12 ++- frontend/search-batch-plan.js | 138 ++++++++++++++++++++++++++++++++ frontend/service-worker.js | 1 + src/frontend_bundle.py | 3 +- tests/test_batch_find_work.py | 35 ++++++++ tests/test_search_batch_plan.py | 96 ++++++++++++++++++++++ tests/test_service_worker.py | 1 + 10 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 frontend/search-batch-plan.js create mode 100644 tests/test_search_batch_plan.py diff --git a/frontend/batch-find-work.js b/frontend/batch-find-work.js index 299a466..5b3eeb2 100644 --- a/frontend/batch-find-work.js +++ b/frontend/batch-find-work.js @@ -7,9 +7,12 @@ function createBatchFindWork({ onProgress = () => {}, storage = typeof localStorage === 'undefined' ? null : localStorage, owner = () => '', + journalName = 'find-work-batch', + autoMount = true, }) { let request = null; - const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || '')); + const journalKey = () => 'stackchain.' + journalName + '.v1.' + + encodeURIComponent(String(owner() || '')); function readJournal() { if (!storage || !String(owner() || '')) return null; @@ -125,6 +128,11 @@ function createBatchFindWork({ return request; } + function pending() { + const journal = readJournal(); + return journal ? journal.items.filter(item => item.state !== 'queued').length : 0; + } + function mountRecovery(button, opener) { const show = () => { const journal = readJournal(); @@ -144,10 +152,10 @@ function createBatchFindWork({ opener.addEventListener('click', show); } - if (typeof document !== 'undefined') mountRecovery( + if (autoMount && typeof document !== 'undefined') mountRecovery( document.getElementById('resume-find-work-batch'), document.getElementById('find-work') ); - return { run, resume, mountRecovery }; + return { run, resume, pending, mountRecovery }; } if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index cb194f6..021f1ac 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -114,6 +114,7 @@ textarea { resize: vertical; min-height: 120px; } #cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); } #cmd-palette.open { display: block; } .cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; } +.cmd-palette-header-actions { display:flex; gap:8px; } .cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; } .cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; } .cmd-search-scope label { font-size:12px; color:#cbd5e1; } @@ -125,6 +126,14 @@ textarea { resize: vertical; min-height: 120px; } .cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } .cmd-meta { color:#93a4b8; font-size:12px; text-align:right; } .cmd-status { padding:10px; color:#93a4b8; font-size:13px; } +.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; } +.cmd-select-result input { width:22px; height:22px; } +.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; } +.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; } +.search-batch-actions[hidden] { display:none; } +.search-batch-actions span { grid-column:1 / -1; } +.search-batch-actions button, .search-batch-recovery { min-height:44px; } +.search-batch-recovery { width:100%; font-weight:700; } .cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } #whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a0c4a38..3b79e5f 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4293,6 +4293,7 @@ let commandSearchState = { status:'idle', query:'', items:[] }; let commandItems = []; let commandSelection = -1; + let searchBatchPlanning = null; async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}, continuation) { const response = await fetch(filterCommands.searchUrl(query, page, scope, continuation), { headers: { Accept:'application/json' }, @@ -4421,6 +4422,11 @@ session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})], onState: renderSearchPreview, }); + searchBatchPlanning = mountSearchBatchPlanning( + document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson, + searchPreviewPath, queueToday, acceptClaimedIssue, index=>commandItems[index]?.result, + ()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr + ); searchDefer = createSearchDefer({ claim: detail => searchPreview.claim(detail), accept: confirmed => acceptClaimedIssue(confirmed), @@ -4510,7 +4516,8 @@ function renderCommands(filter) { const el = qs('#cmd-results'); const loadMore = qs('#cmd-load-more'); - const local = filterCommands(commands, filter).map(command => ({ command })); + const selecting = searchBatchPlanning?.plan.snapshot().active === true; + const local = selecting ? [] : filterCommands(commands, filter).map(command => ({ command })); const remote = commandSearchState.query === String(filter || '').trim() ? commandSearchState.items.map(result => ({ result })) : []; commandItems = local.concat(remote); @@ -4521,6 +4528,7 @@ html += remote.map((item, remoteIdx) => { const idx = local.length + remoteIdx; const result = item.result; + if (selecting) return searchBatchPlanning.resultHtml(result, idx); return '
' + escapeHtml(result.title) + '' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '
'; }).join(''); if (commandSearchState.status === 'loading') html += '
Searching accessible work…
'; @@ -4533,6 +4541,7 @@ el.querySelectorAll('.cmd-item').forEach((item) => { item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)])); }); + } function openCommandPalette(navigate = true) { if (navigate) { @@ -4607,6 +4616,7 @@ qs('#open-palette').addEventListener('click', openCommandPalette); qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore()); + function changeSearchScope() { const scope = currentSearchScope(); commandSelection = -1; diff --git a/frontend/index.html b/frontend/index.html index 9727500..dbdcff6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -426,7 +426,10 @@