diff --git a/README.md b/README.md
index 240c5b5..6101e77 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index db0fba2..b72b7c8 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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' ||
diff --git a/frontend/index.html b/frontend/index.html
index cb8445b..9b66e95 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -468,6 +468,7 @@
+
@@ -1052,6 +1053,7 @@
+
diff --git a/frontend/search-defer.js b/frontend/search-defer.js
new file mode 100644
index 0000000..800a91e
--- /dev/null
+++ b/frontend/search-defer.js
@@ -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;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 99378d4..4b07532 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -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',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index ac9ebe0..89467d2 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -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",
diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py
index b07587d..02a83a4 100644
--- a/tests/test_command_palette.py
+++ b/tests/test_command_palette.py
@@ -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()
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 446fcca..ff84f56 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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",