Merge pull request 'Schedule mobile Search results into Later' (#800)
Closes #799
This commit is contained in:
commit
af9699e362
|
|
@ -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
|
||||
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
|
||||
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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2231,6 +2231,7 @@
|
|||
});
|
||||
const laterPickerElement = qs('#later-picker');
|
||||
const laterPickerInput = qs('#later-picker-time');
|
||||
let searchDefer = null;
|
||||
const laterPicker = createLaterPicker({
|
||||
history: window.history,
|
||||
eventTarget: window,
|
||||
|
|
@ -2249,6 +2250,13 @@
|
|||
}
|
||||
},
|
||||
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') {
|
||||
const inSession = workSession.active();
|
||||
const deferred = detailDefer.deferUntil(item, until, {
|
||||
|
|
@ -4330,6 +4338,7 @@
|
|||
const sheet = qs('#search-preview');
|
||||
const status = qs('#search-preview-status');
|
||||
const claimButton = qs('#claim-search-result');
|
||||
const deferButton = qs('#defer-search-result');
|
||||
const queueButton = qs('#queue-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
const shareButton = qs('#share-search-result');
|
||||
|
|
@ -4343,6 +4352,8 @@
|
|||
sheet.classList.add('open');
|
||||
claimButton.hidden = true;
|
||||
claimButton.disabled = false;
|
||||
deferButton.hidden = true;
|
||||
deferButton.disabled = false;
|
||||
queueButton.hidden = true;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
|
|
@ -4378,6 +4389,10 @@
|
|||
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||
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' &&
|
||||
(detail.claimable || detail.assigned_to_me));
|
||||
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),
|
||||
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() {
|
||||
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -4736,6 +4763,12 @@
|
|||
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 () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||
|
|
|
|||
|
|
@ -468,6 +468,7 @@
|
|||
<div class="search-preview-actions">
|
||||
<div class="search-preview-primary-actions">
|
||||
<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="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
|
|
@ -1052,6 +1053,7 @@
|
|||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.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/drafts.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/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ 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/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/assign-and-start.js", "static/queue-today.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"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||
SEARCH_DEFER = FRONTEND / "search-defer.js"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
|
|
|
|||
|
|
@ -788,6 +788,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user