Batch-plan mobile Search results into Today #810

Merged
timmy merged 1 commits from timmy/809-batch-plan-mobile-search into main 2026-08-14 08:08:45 +00:00
10 changed files with 315 additions and 6 deletions

View File

@ -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;

View File

@ -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); }

View File

@ -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 '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
}).join('');
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
@ -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;

View File

@ -426,7 +426,10 @@
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
<button id="close-command-palette" type="button">Close</button>
<div class="cmd-palette-header-actions">
<button id="select-search-results" type="button">Select</button>
<button id="close-command-palette" type="button">Close</button>
</div>
</div>
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
<fieldset class="cmd-search-scope">
@ -449,8 +452,14 @@
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
<button id="cmd-load-more" hidden>More results</button>
<div class="search-batch-actions" id="search-batch-actions" hidden>
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
@ -1117,6 +1126,7 @@
<script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/batch-find-work.js"></script>
<script src="static/search-batch-plan.js"></script>
<script src="static/conversation.js"></script>
<script src="static/comment-actions.js"></script>
<script src="static/issue-attachment.js"></script>

View File

@ -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 '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, restore, resultHtml };
};
return createSearchBatchPlan;
});

View File

@ -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',

View File

@ -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(

View File

@ -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))});

View File

@ -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

View File

@ -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",