diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 12a2f7d..111174c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -208,6 +208,13 @@ textarea { resize: vertical; min-height: 120px; } .work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; } .milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); } .work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); } +.queue-finder { display:grid; gap:6px; margin:10px 0; } +.queue-finder > label { font-weight:700; } +.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; } +.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; } +.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; } +.queue-finder button { padding-inline:14px; } +#search-older-work { width:100%; } .my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; } .draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); } .draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; } @@ -569,6 +576,7 @@ textarea { resize: vertical; min-height: 120px; } .work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; } .work-settings:not([open]) > .work-settings-panel { display:none; } .work-settings-panel { display:grid; gap:10px; margin-top:8px; } + .queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; } .my-work-list { grid-template-columns:1fr; } .my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); } .my-work-card { min-width:0; overflow-x:hidden; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 88502e4..1243408 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -117,6 +117,7 @@ const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; let selectedWorkFilter = 'all'; let selectedWorkMilestone = 'all'; + let queueFindQuery = ''; let savedWorkFilter = null; let launchFilterResolved = false; try { @@ -2123,7 +2124,8 @@ function renderDrafts() { const list = qs('#my-work-list'); - const deliveryCenter = draftInbox.partition(lastDrafts); + const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery); + const deliveryCenter = draftInbox.partition(displayedDrafts); const renderDraftCard = item => { const index = lastDrafts.indexOf(item); const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; @@ -2194,6 +2196,7 @@ list.innerHTML = deliverySummary + '
' + '

Queued deliveries

' + deliveryCards + '
' + '

Unfinished drafts

' + draftCards + '
'; + updateQueueFinder(displayedDrafts.length, lastDrafts.length, false); qs('#retry-waiting-deliveries').addEventListener('click', async event => { const button = event.currentTarget; if (!activeFlushLogin || !deliveryCenter.retryable.length) return; @@ -2327,11 +2330,13 @@ qs('#load-more-notifications').hidden = true; return; } - const visible = selectedWorkFilter === 'today' ? + const queueItems = selectedWorkFilter === 'today' ? filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ? filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); + const visible = findQueueItems(queueItems, queueFindQuery); + updateQueueFinder(visible.length, queueItems.length, incomplete); const selection = notificationSelection.snapshot(); const selectedIds = new Set(selection.ids); const workSelectionState = workSelection.snapshot(); @@ -5672,6 +5677,36 @@ selectWorkQueue(button.dataset.workFilter); }); }); + function updateQueueFinder(matches, loaded, incomplete) { + const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]'); + const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work'; + qs('#queue-find-label').textContent = queueLabel; + qs('#clear-queue-find').hidden = !queueFindQuery; + qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete; + qs('#queue-find-status').textContent = queueFindQuery ? + (matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel + + (incomplete ? ' among loaded work.' : '.')) : ''; + } + qs('#queue-finder').addEventListener('submit', event => event.preventDefault()); + qs('#queue-find-input').addEventListener('input', event => { + queueFindQuery = event.target.value; + renderMyWork(); + }); + qs('#clear-queue-find').addEventListener('click', () => { + queueFindQuery = ''; + qs('#queue-find-input').value = ''; + renderMyWork(); + qs('#queue-find-input').focus(); + }); + qs('#search-older-work').addEventListener('click', () => { + const notificationButton = qs('#load-more-notifications'); + const workButton = qs('#load-more-work'); + const target = selectedWorkFilter === 'update' ? notificationButton : workButton; + if (!target || target.hidden || target.disabled) return; + qs('#queue-find-status').textContent = 'Searching older ' + + (selectedWorkFilter === 'update' ? 'updates…' : 'work…'); + target.click(); + }); function selectWorkQueue(filter, { preserveRoute = false } = {}) { const button = qs('[data-work-filter="' + filter + '"]'); if (!button) return false; diff --git a/frontend/index.html b/frontend/index.html index ce42380..0391cff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -169,6 +169,15 @@ +
diff --git a/frontend/my-work.js b/frontend/my-work.js index f2cf02b..2c19551 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -814,6 +814,16 @@ function summarizeMyWork(items) { return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel; } +function findQueueItems(items, query) { + const needle = String(query || '').trim().toLowerCase(); + if (!needle) return (items || []).slice(); + return (items || []).filter(item => { + const number = Number.isInteger(item?.number) ? '#' + item.number : ''; + return [item?.repository, item?.key, number, item?.title] + .some(value => String(value || '').toLowerCase().includes(needle)); + }); +} + function countMyWork(items) { return { all: items.length, @@ -837,6 +847,7 @@ if (typeof module !== 'undefined' && module.exports) { buildMyWork.replaceIssueMilestone = replaceIssueMilestone; buildMyWork.removeIssue = removeIssue; buildMyWork.summarizeMyWork = summarizeMyWork; + buildMyWork.findQueueItems = findQueueItems; buildMyWork.countMyWork = countMyWork; buildMyWork.acknowledgeNotification = acknowledgeNotification; buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index b7f2842..2cb644a 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v96'; +const CACHE = 'stackchain-dashboard-shell-v97'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index c9cb61c..0ef91ec 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v96" in worker + assert "stackchain-dashboard-shell-v97" in worker diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 85beef0..ca3f66e 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -313,7 +313,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w assert 'Sending ' in html assert 'Needs attention ' in html assert 'Authorize ' in html - assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html + assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html assert "deliveryCenter.retryable.length" in html assert "item.status === 'sending' ? 'Sending'" in html diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 54cd2aa..cb342b4 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -69,7 +69,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes # The recap adds only startup wiring; its UI remains in the lazy Today bundle. - assert len(first.runtime_gzip_bytes) <= 95 * 1024 + assert len(first.runtime_gzip_bytes) <= 96 * 1024 assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source @@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v96';", + "const CACHE = 'stackchain-dashboard-shell-v97';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index f0717b9..5e86229 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v96" in source + assert "stackchain-dashboard-shell-v97" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 944d2e3..c009696 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v96" in worker + assert "stackchain-dashboard-shell-v97" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index da902f7..1874070 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v96" in worker + assert "stackchain-dashboard-shell-v97" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 5e10eb8..4adb96d 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v96" in worker + assert "stackchain-dashboard-shell-v97" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 17279a2..17503fc 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -29,6 +29,47 @@ TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js" +def test_queue_finder_matches_repository_number_and_title_without_reordering(): + script = f""" +const work = require({json.dumps(str(MY_WORK))}); +const items = [ + {{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}}, + {{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}}, + {{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}}, +]; +process.stdout.write(JSON.stringify({{ + repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key), + number:work.findQueueItems(items, '#42').map(item => item.key), + title:work.findQueueItems(items, 'mobile queue').map(item => item.key), + clear:work.findQueueItems(items, ' ').map(item => item.key), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "repo": ["stackchain/api#42"], + "number": ["stackchain/api#42", "other/repo#42"], + "title": ["stackchain/web#7"], + "clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"], + } + + +@pytest.mark.anyio +async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search(): + html = await dashboard() + + assert '