feat: schedule search results into Later (Closes #799)
All checks were successful
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 04:30:02 +00:00
parent 0a8f33dc83
commit 71b38687c4
8 changed files with 167 additions and 1 deletions

View File

@ -63,6 +63,12 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
let operators assign an eligible issue directly into Later at an exact local return time without filling
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work. let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued The queue action keeps the Search query, filters, results, and scroll position available for continued
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and

View File

@ -2231,6 +2231,7 @@
}); });
const laterPickerElement = qs('#later-picker'); const laterPickerElement = qs('#later-picker');
const laterPickerInput = qs('#later-picker-time'); const laterPickerInput = qs('#later-picker-time');
let searchDefer = null;
const laterPicker = createLaterPicker({ const laterPicker = createLaterPicker({
history: window.history, history: window.history,
eventTarget: window, eventTarget: window,
@ -2249,6 +2250,13 @@
} }
}, },
onConfirm: (item, until, context) => { onConfirm: (item, until, context) => {
if (context === 'search') {
return () => searchDefer.run(item, until).then(outcome => {
if (outcome === 'deferred') closeSearchPreview();
}).catch(error => {
qs('#search-preview-status').textContent = error.message + ' Retry.';
});
}
if (context === 'detail') { if (context === 'detail') {
const inSession = workSession.active(); const inSession = workSession.active();
const deferred = detailDefer.deferUntil(item, until, { const deferred = detailDefer.deferUntil(item, until, {
@ -4330,6 +4338,7 @@
const sheet = qs('#search-preview'); const sheet = qs('#search-preview');
const status = qs('#search-preview-status'); const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result'); const claimButton = qs('#claim-search-result');
const deferButton = qs('#defer-search-result');
const queueButton = qs('#queue-search-result'); const queueButton = qs('#queue-search-result');
const startButton = qs('#start-search-result'); const startButton = qs('#start-search-result');
const shareButton = qs('#share-search-result'); const shareButton = qs('#share-search-result');
@ -4343,6 +4352,8 @@
sheet.classList.add('open'); sheet.classList.add('open');
claimButton.hidden = true; claimButton.hidden = true;
claimButton.disabled = false; claimButton.disabled = false;
deferButton.hidden = true;
deferButton.disabled = false;
queueButton.hidden = true; queueButton.hidden = true;
startButton.hidden = true; startButton.hidden = true;
startButton.disabled = false; startButton.disabled = false;
@ -4378,6 +4389,10 @@
claimButton.hidden = !(detail.claimable || detail.assigned_to_me); claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming'; claimButton.disabled = state.status === 'claiming';
deferButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
deferButton.textContent = detail.assigned_to_me ? 'Defer' : 'Assign & defer';
deferButton.disabled = state.status === 'claiming' || searchDefer?.pending();
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' && queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me)); (detail.claimable || detail.assigned_to_me));
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'; queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
@ -4409,6 +4424,18 @@
share: url => createWorkRoute.share(url, navigator, navigator.clipboard), share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
onState: renderSearchPreview, onState: renderSearchPreview,
}); });
searchDefer = createSearchDefer({
claim: detail => searchPreview.claim(detail),
accept: confirmed => acceptClaimedIssue(confirmed),
defer: (item, until) => laterWork.defer(item, until),
refresh: refreshMyWorkView,
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
formatTime: fmt,
});
function canonicalSearchPreviewUrl() { function canonicalSearchPreviewUrl() {
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState(); const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -4736,6 +4763,12 @@
qs('#search-preview-status').textContent = error.message + ' Retry assignment.'; qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
} }
}); });
qs('#defer-search-result').addEventListener('click', event => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
laterPicker.open(detail, event.currentTarget, 'search');
});
qs('#queue-search-result').addEventListener('click', async () => { qs('#queue-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail; const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' || if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||

View File

@ -468,6 +468,7 @@
<div class="search-preview-actions"> <div class="search-preview-actions">
<div class="search-preview-primary-actions"> <div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button> <button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="defer-search-result" type="button" hidden>Assign &amp; defer</button>
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button> <button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button> <button id="start-search-result" type="button" hidden>Assign &amp; start</button>
@ -1052,6 +1053,7 @@
<script src="static/markdown.js"></script> <script src="static/markdown.js"></script>
<script src="static/commands.js"></script> <script src="static/commands.js"></script>
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/drafts.js"></script> <script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script> <script src="static/unfiled-captures.js"></script>

28
frontend/search-defer.js Normal file
View File

@ -0,0 +1,28 @@
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
let request = null;
function run(detail, until) {
if (request) return request;
const wasClaimable = Boolean(detail?.claimable);
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
.then(confirmed => {
const item = accept(confirmed);
const outcome = defer(item, until);
refresh();
if (outcome === 'deferred') {
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
} else {
announce(wasClaimable
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
: 'Could not save Later on this device. Retry scheduling.');
}
return outcome;
})
.finally(() => { request = null; });
return request;
}
return { run, pending:() => Boolean(request) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;

View File

@ -18,6 +18,7 @@ const SHELL = [
BASE + 'static/markdown.js', BASE + 'static/markdown.js',
BASE + 'static/commands.js', BASE + 'static/commands.js',
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js', BASE + 'static/widgets.js',
BASE + 'static/drafts.js', BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js', BASE + 'static/unfiled-captures.js',

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.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/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/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/assign-and-start.js", "static/queue-today.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/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",

View File

@ -11,6 +11,7 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js" COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js" SEARCH_PREVIEW = FRONTEND / "search-preview.js"
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js" MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
SEARCH_DEFER = FRONTEND / "search-defer.js"
class ScriptSourceParser(HTMLParser): class ScriptSourceParser(HTMLParser):
@ -552,6 +553,100 @@ def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run") assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
script = f"""
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
(async () => {{
const calls = [];
let releaseClaim;
const flow = createSearchDefer({{
claim: detail => {{
calls.push(['claim', detail.number]);
return new Promise(resolve => releaseClaim = resolve);
}},
accept: confirmed => {{
calls.push(['accept', confirmed.key]);
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
}},
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
refresh: () => calls.push(['refresh']),
announce: message => calls.push(['announce', message]),
formatTime: value => value.toISOString(),
}});
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
const until = new Date('2026-08-15T09:00:00.000Z');
const first = flow.run(detail, until);
const second = flow.run(detail, until);
if (first !== second) throw new Error('confirmation was not single-flight');
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
const outcome = await first;
process.stdout.write(JSON.stringify({{outcome, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"outcome": "deferred",
"calls": [
["claim", 42],
["accept", "stackchain/api#42"],
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
["refresh"],
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
],
}
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="defer-search-result"' in html
assert "const deferButton = qs('#defer-search-result')" in html
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
"qs('#queue-search-result')", 1
)[0]
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
assert "searchPreview.claim" not in handler
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
assert "@media (max-width:420px)" in css
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
script = f"""
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
(async () => {{
const messages = [];
let refreshes = 0;
const flow = createSearchDefer({{
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
accept: issue => issue,
defer: () => 'unavailable',
refresh: () => refreshes += 1,
announce: message => messages.push(message),
formatTime: value => value.toISOString(),
}});
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
new Date('2026-08-15T09:00:00.000Z'));
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"outcome": "unavailable",
"refreshes": 1,
"messages": [
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
],
}
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard(): def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text() html = dashboard_bundle_text()

View File

@ -788,6 +788,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/markdown.js", "/dashboard/static/markdown.js",
"/dashboard/static/commands.js", "/dashboard/static/commands.js",
"/dashboard/static/search-preview.js", "/dashboard/static/search-preview.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",
"/dashboard/static/drafts.js", "/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js", "/dashboard/static/unfiled-captures.js",