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 @@
+
+
+ No issues selected.
+
+
+
@@ -1117,6 +1126,7 @@
+
diff --git a/frontend/search-batch-plan.js b/frontend/search-batch-plan.js
new file mode 100644
index 0000000..aca6bab
--- /dev/null
+++ b/frontend/search-batch-plan.js
@@ -0,0 +1,138 @@
+(function (root, factory) {
+ const createSearchBatchPlan = factory();
+ if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
+ if (root) {
+ root.createSearchBatchPlan = createSearchBatchPlan;
+ root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
+ }
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ function createSearchBatchPlan({
+ limit = 50,
+ resolve = item => Promise.resolve(item),
+ claim = item => Promise.resolve(item),
+ onChange = () => {},
+ } = {}) {
+ const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
+ const selected = new Map();
+ let active = false;
+
+ const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
+ const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
+ Boolean(item.repository) && Number.isInteger(item.number);
+ function snapshot() {
+ return { active, count:selected.size, items:Array.from(selected.values()) };
+ }
+ function changed() {
+ const state = snapshot();
+ onChange(state);
+ return state;
+ }
+ function start() {
+ active = true;
+ selected.clear();
+ return changed();
+ }
+ function cancel() {
+ active = false;
+ selected.clear();
+ return changed();
+ }
+ function toggle(item) {
+ if (!active) return 'inactive';
+ if (!eligible(item)) return 'ineligible';
+ const key = identity(item);
+ if (selected.has(key)) {
+ selected.delete(key);
+ changed();
+ return 'removed';
+ }
+ if (selected.size >= maximum) return 'limit';
+ selected.set(key, { ...item });
+ changed();
+ return 'selected';
+ }
+ async function prepare(item) {
+ const detail = await resolve(item);
+ if (detail?.assigned_to_me) return detail;
+ if (detail?.claimable) return claim(detail);
+ throw new Error('This issue is no longer available to assign.');
+ }
+ return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
+ }
+
+ createSearchBatchPlan.mount = function mountSearchBatchPlanning(
+ document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
+ acceptIssue, lookup, render, escapeHtml, escapeAttribute
+ ) {
+ const get = selector => document.querySelector(selector);
+ let processor;
+ const plan = createSearchBatchPlan({
+ resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
+ claim:detail => fetchJson(
+ 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
+ '/issues/' + encodeURIComponent(detail.number) + '/claim',
+ {method:'PATCH', headers:{Accept:'application/json'}}
+ ),
+ onChange:state => {
+ get('#search-batch-actions').hidden = !state.active;
+ get('#select-search-results').hidden = state.active;
+ get('#queue-selected-search-results').disabled = state.count === 0;
+ get('#search-selection-status').textContent = state.count ?
+ state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
+ render();
+ },
+ });
+ processor = batchFactory({
+ capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
+ owner:getOwner,
+ journalName:'search-today-batch',
+ autoMount:false,
+ claim:item => plan.prepare(item),
+ queue:confirmed => queueToday(acceptIssue(confirmed)),
+ onProgress:progress => {
+ get('#queue-selected-search-results').disabled = progress.status === 'running';
+ if (progress.status === 'running') {
+ get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
+ } else if (progress.status === 'full') {
+ get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
+ (progress.available === 1 ? '.' : 's.');
+ } else if (progress.status === 'complete') {
+ get('#cmd-search-action-status').textContent = progress.failed.length ?
+ progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
+ progress.queued.length + ' added to Today.';
+ get('#resume-search-batch').hidden = processor.pending() === 0;
+ if (!progress.failed.length) plan.cancel();
+ }
+ },
+ });
+ get('#select-search-results').addEventListener('click', () => plan.start());
+ get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
+ get('#queue-selected-search-results').addEventListener('click', () => processor.run(plan.snapshot().items));
+ get('#resume-search-batch').addEventListener('click', () => processor.resume());
+ get('#open-palette').addEventListener('click', () => restore());
+ get('#cmd-results').addEventListener('change', event => {
+ const index = event.target?.dataset?.searchSelect;
+ if (index === undefined) return;
+ const result = lookup(Number(index));
+ if (result) plan.toggle(result);
+ });
+ function restore() {
+ const pending = processor.pending();
+ get('#resume-search-batch').hidden = pending === 0;
+ get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
+ }
+ function resultHtml(result, index) {
+ const allowed = result.kind === 'issue' && result.state === 'open';
+ const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
+ return '';
+ }
+ return { plan, processor, restore, resultHtml };
+ };
+
+ return createSearchBatchPlan;
+});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 4b07532..0df4b65 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -60,6 +60,7 @@ const SHELL = [
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/batch-find-work.js',
+ BASE + 'static/search-batch-plan.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-attachment.js',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 89467d2..e6d4124 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -28,11 +28,12 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
- "static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
+ "static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
+ "static/search-batch-plan.js",
),
}
CACHE_DECLARATION = re.compile(
diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py
index 46406d3..88c0fa0 100644
--- a/tests/test_batch_find_work.py
+++ b/tests/test_batch_find_work.py
@@ -185,6 +185,41 @@ flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})
}
+def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
+ script = f"""
+const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+const values={{}};
+const calls=[];
+const storage={{
+ getItem:key=>values[key] || null,
+ setItem:(key,value)=>{{values[key]=value;}},
+ removeItem:key=>{{delete values[key];}},
+}};
+const flow=createBatchFindWork({{
+ capacity:()=>1, owner:()=> 'timmy', storage,
+ journalName:'search-today-batch', autoMount:false,
+ claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
+ queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
+}});
+flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
+ process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
+"""
+
+ assert run_node(script) == {
+ "result": {
+ "status": "complete", "selected": 1, "available": 1, "queued": [],
+ "failed": [{
+ "key": "stackchain/dashboard#809",
+ "reason": "assigned but Today sync is unavailable",
+ "assigned": True,
+ }],
+ },
+ "keys": ["stackchain.search-today-batch.v1.timmy"],
+ "calls": ["queue"],
+ "pending": 1,
+ }
+
+
def test_time_budget_blocks_claims_until_every_estimate_fits():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
diff --git a/tests/test_search_batch_plan.py b/tests/test_search_batch_plan.py
new file mode 100644
index 0000000..dae9197
--- /dev/null
+++ b/tests/test_search_batch_plan.py
@@ -0,0 +1,96 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+SEARCH_BATCH_PLAN = Path(__file__).parents[1] / "frontend" / "search-batch-plan.js"
+HTML = Path(__file__).parents[1] / "frontend" / "index.html"
+DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
+CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
+
+
+def run_node(script):
+ return json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+
+def test_search_batch_selection_keeps_open_issues_across_pages_and_rejects_ineligible_results():
+ script = f"""
+const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
+const states=[];
+const flow=createSearchBatchPlan({{onChange:state=>states.push(state)}});
+const first={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,title:'First'}};
+const second={{kind:'issue',state:'open',repository:'stackchain/api',number:17,title:'Second'}};
+const pull={{kind:'pull',state:'open',repository:'stackchain/dashboard',number:810}};
+const closed={{kind:'issue',state:'closed',repository:'stackchain/dashboard',number:808}};
+flow.start();
+const outcomes=[flow.toggle(first),flow.toggle(pull),flow.toggle(closed),flow.toggle(second)];
+process.stdout.write(JSON.stringify({{outcomes,snapshot:flow.snapshot(),states}}));
+"""
+
+ result = run_node(script)
+ assert result["outcomes"] == ["selected", "ineligible", "ineligible", "selected"]
+ assert result["snapshot"] == {
+ "active": True,
+ "count": 2,
+ "items": [
+ {"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 809, "title": "First"},
+ {"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 17, "title": "Second"},
+ ],
+ }
+ assert result["states"][-1]["count"] == 2
+
+
+def test_search_batch_prepares_owned_issues_without_reclaiming_and_claims_unassigned_issues():
+ script = f"""
+const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
+const calls=[];
+const details={{
+ 809:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,assigned_to_me:true}},
+ 810:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:810,claimable:true}},
+ 811:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,claimable:false}},
+}};
+const flow=createSearchBatchPlan({{
+ resolve:item=>{{calls.push('resolve:'+item.number);return Promise.resolve(details[item.number]);}},
+ claim:detail=>{{calls.push('claim:'+detail.number);return Promise.resolve({{...detail,assigned_to_me:true}});}},
+}});
+Promise.all([
+ flow.prepare({{number:809}}),
+ flow.prepare({{number:810}}),
+ flow.prepare({{number:811}}).catch(error=>error.message),
+]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
+"""
+
+ result = run_node(script)
+ assert result["calls"] == ["resolve:809", "resolve:810", "resolve:811", "claim:810"]
+ assert result["results"][0]["assigned_to_me"] is True
+ assert result["results"][1]["assigned_to_me"] is True
+ assert result["results"][2] == "This issue is no longer available to assign."
+
+
+def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
+ html = HTML.read_text()
+ dashboard = DASHBOARD.read_text()
+ css = CSS.read_text()
+ worker = (HTML.parent / "service-worker.js").read_text()
+ controller = SEARCH_BATCH_PLAN.read_text()
+
+ assert 'id="select-search-results"' in html
+ assert 'id="search-batch-actions"' in html
+ assert 'id="queue-selected-search-results"' in html
+ assert 'id="resume-search-batch"' in html
+ assert 'src="static/search-batch-plan.js"' in html
+ assert "mountSearchBatchPlanning(" in dashboard
+ assert "journalName:'search-today-batch'" in controller
+ assert "autoMount:false" in controller
+ assert "plan.toggle(result)" in controller
+ assert "processor.run(plan.snapshot().items)" in controller
+ assert "processor.resume()" in controller
+ assert 'aria-label="Select ' in controller
+ assert "result.kind === 'issue' && result.state === 'open'" in controller
+ assert ".search-batch-actions" in css
+ assert "position:sticky" in css
+ assert "env(safe-area-inset-bottom)" in css
+ assert ".cmd-select-result" in css and "min-height:44px" in css
+ assert "BASE + 'static/search-batch-plan.js'" in worker
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index ff84f56..07208ea 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -830,6 +830,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/batch-find-work.js",
+ "/dashboard/static/search-batch-plan.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-attachment.js",