feat: schedule search results into Later (Closes #799)
This commit is contained in:
parent
0a8f33dc83
commit
71b38687c4
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' ||
|
||||||
|
|
|
||||||
|
|
@ -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 & defer</button>
|
||||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
<button id="start-search-result" type="button" hidden>Assign & 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
28
frontend/search-defer.js
Normal 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;
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user