Merge pull request 'Queue mobile Search results into Today' (#798) from timmy/797-queue-mobile-search-results into main
All checks were successful
CI / lint (push) Successful in 1m45s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
timmy 2026-08-14 04:08:20 +00:00
commit 0a8f33dc83
7 changed files with 80 additions and 1 deletions

View File

@ -62,7 +62,11 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
the private content. Issue capture and authored mobile actions (issue 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. The ordered, another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
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
offline-warm path as other Today admission flows. The ordered,
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work, time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,

View File

@ -549,6 +549,9 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; } .search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
@media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; }
}
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } .markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; } .markdown-content > :first-child { margin-top:0; }

View File

@ -4330,6 +4330,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 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');
@ -4342,6 +4343,7 @@
sheet.classList.add('open'); sheet.classList.add('open');
claimButton.hidden = true; claimButton.hidden = true;
claimButton.disabled = false; claimButton.disabled = false;
queueButton.hidden = true;
startButton.hidden = true; startButton.hidden = true;
startButton.disabled = false; startButton.disabled = false;
shareButton.disabled = true; shareButton.disabled = true;
@ -4376,6 +4378,9 @@
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';
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';
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' && startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me))))); (detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' : startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
@ -4423,6 +4428,7 @@
refreshMyWorkView(); refreshMyWorkView();
return createAndStart.complete(item); return createAndStart.complete(item);
}, },
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
recover: confirmed => { recover: confirmed => {
const item = acceptClaimedIssue(confirmed); const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave(); taskOverlayHistory.leave();
@ -4431,6 +4437,7 @@
}, },
announce: message => { announce: message => {
qs('#search-preview-status').textContent = message; qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message; qs('#my-work-action-status').textContent = message;
}, },
}); });
@ -4729,6 +4736,25 @@
qs('#search-preview-status').textContent = error.message + ' Retry assignment.'; qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
} }
}); });
qs('#queue-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
try {
if (todayWork.contains(detail)) {
qs('#cmd-search-action-status').textContent = 'Already in Today.';
closeSearchPreview();
return;
}
const outcome = await searchAssignAndStart.run(detail, {
alreadyOwned: detail.assigned_to_me,
destination: 'queue',
});
if (outcome === 'queued') closeSearchPreview();
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#start-search-result').addEventListener('click', async () => { qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail; const detail = searchPreviewDetail;
if (detail?.reviewable) { if (detail?.reviewable) {

View File

@ -448,6 +448,7 @@
<datalist id="cmd-search-repositories"></datalist> <datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span> <span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset> </fieldset>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div> <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> <button id="cmd-load-more" hidden>More results</button>
</div> </div>
@ -467,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="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>
</div> </div>

View File

@ -30,6 +30,7 @@ FEATURE_SOURCES = {
"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/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/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",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
), ),

View File

@ -513,6 +513,45 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_search_preview_queues_eligible_issue_and_returns_to_preserved_search():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="queue-search-result"' in html
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
"qs('#start-search-result')", 1
)[0]
assert "destination: 'queue'" in handler
assert "alreadyOwned: detail.assigned_to_me" in handler
assert "closeSearchPreview()" in handler
assert "mobileSearchViewport.restoreScroll()" in html
assert "@media (max-width:420px)" in css
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
html = dashboard_bundle_text()
assert 'id="cmd-search-action-status"' in html
assert 'aria-live="polite"' in html
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
"qs('#start-search-result')", 1
)[0]
assert "todayWork.contains(detail)" in handler
assert "Already in Today." in handler
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
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

@ -68,6 +68,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function attachSecurityCenter" in security_center.runtime_bytes assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"function createGlobalSearchController" not in first.runtime_bytes assert b"function createGlobalSearchController" not in first.runtime_bytes
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createAssignAndStart" not in first.runtime_bytes
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions. # Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.