diff --git a/README.md b/README.md index 9d4d60f..240c5b5 100644 --- a/README.md +++ b/README.md @@ -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 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. 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 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, diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 0f92001..9cf0ad0 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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 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; } +@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; } .markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } .markdown-content > :first-child { margin-top:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b14a8d3..db0fba2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4330,6 +4330,7 @@ const sheet = qs('#search-preview'); const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); + const queueButton = qs('#queue-search-result'); const startButton = qs('#start-search-result'); const shareButton = qs('#share-search-result'); @@ -4342,6 +4343,7 @@ sheet.classList.add('open'); claimButton.hidden = true; claimButton.disabled = false; + queueButton.hidden = true; startButton.hidden = true; startButton.disabled = false; shareButton.disabled = true; @@ -4376,6 +4378,9 @@ 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'; + 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' && (detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me))))); startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' : @@ -4423,6 +4428,7 @@ refreshMyWorkView(); return createAndStart.complete(item); }, + queue: confirmed => queueToday(acceptClaimedIssue(confirmed)), recover: confirmed => { const item = acceptClaimedIssue(confirmed); taskOverlayHistory.leave(); @@ -4431,6 +4437,7 @@ }, announce: message => { qs('#search-preview-status').textContent = message; + qs('#cmd-search-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('#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 () => { const detail = searchPreviewDetail; if (detail?.reviewable) { diff --git a/frontend/index.html b/frontend/index.html index 06591d8..cb8445b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -448,6 +448,7 @@ +
@@ -467,6 +468,7 @@
+
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 77cd107..ac9ebe0 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -30,6 +30,7 @@ FEATURE_SOURCES = { "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/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/today-work.js", "static/pick-work.js", "static/batch-find-work.js", ), diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index fec4c8a..b07587d 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -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 +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(): html = dashboard_bundle_text() diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index b159917..3355666 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -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 createGlobalSearchController" not in first.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" in security_center.runtime_bytes # Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.