From 8b2fd305cacceaa11e6101a61035d60ab1995c06 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 07:12:27 +0000 Subject: [PATCH 1/2] feat: prioritize adaptive mobile queues Closes #1423 --- frontend/dashboard.css | 13 ++- frontend/dashboard.js | 15 ++++ frontend/index.html | 50 +++++++---- frontend/mobile-queue-launcher.js | 57 +++++++++++- .../test_adaptive_mobile_queues_release.py | 61 +++++++++++++ tests/test_mobile_task_dock.py | 88 +++++++++++++++++++ 6 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/test_adaptive_mobile_queues_release.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index fdd0985..5bc91c8 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1496,7 +1496,7 @@ textarea { resize: vertical; min-height: 120px; } .composer-keyboard-active .review-inline-composer, .composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); } .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } - .mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } + .mobile-queue-sheet { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } .following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; } .mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } @@ -1509,9 +1509,18 @@ textarea { resize: vertical; min-height: 120px; } .mobile-first-task-actions button:disabled { opacity:.55; } .mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); } - .mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } + .mobile-queue-panel { box-sizing:border-box; max-height:100dvh; overflow-y:auto; overscroll-behavior:contain; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } .mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; } .mobile-queue-panel h2 { margin:0; } + .mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; } + .mobile-queue-next p, .mobile-queue-group h3 { margin:0; } + .mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; } + .mobile-queue-group { margin-top:16px; } + .mobile-queue-group h3 { font-size:1rem; } + .mobile-queue-all { margin-top:16px; } + .mobile-queue-all summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; } + .mobile-queue-list [data-unavailable="true"] { border-color:#f59e0b; } + .mobile-queue-list [data-unavailable="true"]::after { content:'Sync unavailable · open to retry'; color:#fbbf24; font-size:.75rem; } .mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } .mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); } .mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1c51474..6fab9be 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -110,6 +110,7 @@ } let queueCounts = {}; let preparationItems = {}; + let renderMobileQueuePresentation = () => {}; const followingQueue = attachFollowing(item => { searchPreviewReturnKind = 'following'; return searchPreview.open(item); @@ -119,11 +120,13 @@ queueCounts.following = count; queueCounts.followingUnavailable = false; preparationItems.following = items.filter(item => item.has_unseen_change === true); + renderMobileQueuePresentation(); mobileStartDay.render(); }, onStatus:status => { if (status === 'loading') return; queueCounts.followingUnavailable = status === 'error'; + renderMobileQueuePresentation(); mobileStartDay.render(); }, onReviewComplete:() => mobileStartDay.completePhase('following'), @@ -175,7 +178,18 @@ announce: announceWork, getCounts: () => queueCounts, openFindWork: () => qs('#find-work').click(), + rows: Object.fromEntries( + Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button]) + ), + nextAction: qs('#mobile-queue-next-action'), + activeList: qs('#mobile-queue-active-list'), + planningList: qs('#mobile-queue-planning-list'), + allList: qs('#mobile-queue-all-list'), + activeSection: qs('#mobile-queue-active-list').parentElement, }); + renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation(); + renderMobileQueuePresentation(); + qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork()); function openMobileStartDay() { followingQueue.load().catch(() => {}); const state = mobileStartDay.state(); @@ -3530,6 +3544,7 @@ if (element) element.textContent = count; }); queueCounts = counts; + mobileQueueLauncher.renderPresentation(); mobileStartDay.reconcile({ authoritative:authoritativeMyWorkRefresh, authoritativePhases:['delivery'], diff --git a/frontend/index.html b/frontend/index.html index 2d8d05a..dd047c9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2174,23 +2174,39 @@ -
- - - - - - - - - - - - - - - -
+
+

Next up

+ +
+ +
+

Plan & organize

+
+ + + +
+
+
+ All queues +
+ + + + + + + + + + + + +
+
diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index e7830a8..b4f9d9e 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -19,6 +19,11 @@ ['later', 'Start Later'], ['draft', 'Open Drafts'], ]; + const activeQueueNames = continuation.map(([name]) => name).concat(['following', 'authored']); + const allQueues = [ + 'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention', + 'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps', + ]; function recommend() { const counts = options.getCounts ? options.getCounts() : {}; @@ -29,6 +34,56 @@ return {name, count, label: label + ' (' + count + ')'}; } + function presentation() { + const counts = options.getCounts ? options.getCounts() : {}; + const active = activeQueueNames + .map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)})) + .filter(item => item.count > 0); + activeQueueNames.forEach(name => { + if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) { + active.push({name, unavailable: true}); + } + }); + return { + nextUp: recommend(), + active, + planning: ['today', 'tomorrow', 'week'], + all: allQueues.slice(), + }; + } + + function renderPresentation() { + const view = presentation(); + const planning = new Set(view.planning); + const active = view.active.filter(item => !planning.has(item.name)); + const activeNames = new Set(active.map(item => item.name)); + if (options.nextAction) { + options.nextAction.textContent = view.nextUp.label; + options.nextAction.dataset.queue = view.nextUp.name; + options.nextAction.setAttribute('aria-label', 'Next up: ' + view.nextUp.label); + } + active.forEach(item => { + const row = options.rows?.[item.name]; + if (!row) return; + if (item.unavailable) row.setAttribute('data-unavailable', 'true'); + else row.removeAttribute('data-unavailable'); + options.activeList?.append(row); + }); + view.planning.forEach(name => { + const row = options.rows?.[name]; + if (row) options.planningList?.append(row); + }); + view.all.forEach(name => { + if (planning.has(name) || activeNames.has(name)) return; + const row = options.rows?.[name]; + if (!row) return; + row.removeAttribute('data-unavailable'); + options.allList?.append(row); + }); + if (options.activeSection) options.activeSection.hidden = active.length === 0; + return view; + } + function open(name) { if (name === 'delivery' && options.openDelivery) return options.openDelivery(); if (name === 'gate' && options.openHumanGates) return options.openHumanGates(); @@ -56,5 +111,5 @@ return open(next.name); } - return { open, recommend, continueWork }; + return { open, recommend, presentation, renderPresentation, continueWork }; }); diff --git a/tests/e2e/test_adaptive_mobile_queues_release.py b/tests/e2e/test_adaptive_mobile_queues_release.py new file mode 100644 index 0000000..57d93e3 --- /dev/null +++ b/tests/e2e/test_adaptive_mobile_queues_release.py @@ -0,0 +1,61 @@ +import os +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("packaged adaptive Queues journey runs only in its gated CI job", allow_module_level=True) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + + +ROOT = Path(__file__).parents[2] +FRONTEND = ROOT / "frontend" + + +@pytest.mark.parametrize("viewport", [ + {"width": 320, "height": 568}, + {"width": 375, "height": 667}, + {"width": 430, "height": 932}, +]) +def test_adaptive_queues_put_truthful_next_action_above_the_fold(viewport): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport=viewport) + page.set_content((FRONTEND / "index.html").read_text()) + page.add_style_tag(path=FRONTEND / "dashboard.css") + page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js") + page.evaluate("""() => { + const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]')) + .map(row => [row.dataset.mobileQueue, row])); + window.adaptiveQueueLauncher = createMobileQueueLauncher({ + getCounts:() => ({delivery:1, gate:2, today:3, attentionUnavailable:true}), + rows, + nextAction:document.querySelector('#mobile-queue-next-action'), + activeList:document.querySelector('#mobile-queue-active-list'), + planningList:document.querySelector('#mobile-queue-planning-list'), + allList:document.querySelector('#mobile-queue-all-list'), + activeSection:document.querySelector('#mobile-queue-active-list').parentElement, + }); + window.adaptiveQueueLauncher.renderPresentation(); + document.querySelector('#mobile-queue-sheet').showModal(); + }""") + + next_action = page.locator("#mobile-queue-next-action") + expect(next_action).to_be_visible() + expect(next_action).to_have_text("Recover Delivery (1)") + bounds = next_action.bounding_box() + assert bounds and bounds["height"] >= 44 + assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"] + assert bounds["y"] + bounds["height"] <= viewport["height"] + + assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all( + "rows => rows.map(row => row.dataset.mobileQueue)" + ) == ["delivery", "gate", "attention"] + expect(page.locator('[data-mobile-queue="attention"]')).to_have_attribute("data-unavailable", "true") + assert page.locator("#mobile-queue-planning-list [data-mobile-queue]").evaluate_all( + "rows => rows.map(row => row.dataset.mobileQueue)" + ) == ["today", "tomorrow", "week"] + expect(page.locator(".mobile-queue-all")).not_to_have_attribute("open", "") + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + browser.close() diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index 2062b53..b13b4aa 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -279,6 +279,27 @@ process.stdout.write(JSON.stringify({{calls, open:queues.open}})); } +@pytest.mark.anyio +async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_duplicate_rows(): + html = await dashboard() + + assert 'aria-labelledby="mobile-queue-next-heading"' in html + assert 'id="mobile-queue-next-action"' in html + assert 'aria-labelledby="mobile-queue-active-heading"' in html + assert 'id="mobile-queue-active-list"' in html + assert 'aria-labelledby="mobile-queue-planning-heading"' in html + assert 'id="mobile-queue-planning-list"' in html + assert '
counts}}); +process.stdout.write(JSON.stringify(launcher.presentation())); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "nextUp": {"name": "delivery", "count": 1, "label": "Recover Delivery (1)"}, + "active": [ + {"name": "delivery", "count": 1}, + {"name": "gate", "count": 2}, + {"name": "today", "count": 3}, + {"name": "authored", "count": 4}, + {"name": "attention", "unavailable": True}, + {"name": "following", "unavailable": True}, + ], + "planning": ["today", "tomorrow", "week"], + "all": [ + "today", "tomorrow", "week", "agenda", "delivery", "gate", + "attention", "update", "following", "filed", "authored", "later", + "draft", "find", "recaps", + ], + } + + +def test_mobile_queue_launcher_renders_one_set_of_rows_into_adaptive_groups(): + script = f""" +const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); +class Box {{ + constructor(name) {{ this.name=name; this.children=[]; this.hidden=false; this.attributes={{}}; this.textContent=''; this.dataset={{}}; }} + append(row) {{ if (row.parent) row.parent.children=row.parent.children.filter(item => item !== row); this.children.push(row); row.parent=this; }} + setAttribute(name, value) {{ this.attributes[name]=value; }} + removeAttribute(name) {{ delete this.attributes[name]; }} +}} +const names=['today','tomorrow','week','agenda','delivery','gate','attention','update','following','filed','authored','later','draft','find','recaps']; +const rows=Object.fromEntries(names.map(name => [name,new Box(name)])); +const nextAction=new Box('next'); +const activeList=new Box('active'); const planningList=new Box('planning'); const allList=new Box('all'); +const activeSection=new Box('active-section'); +const launcher=createLauncher({{ + getCounts:()=>({{delivery:1,gate:2,today:3,attentionUnavailable:true}}), rows, + nextAction, activeList, planningList, allList, activeSection, +}}); +launcher.renderPresentation(); +process.stdout.write(JSON.stringify({{ + next:[nextAction.textContent,nextAction.dataset.queue,nextAction.attributes['aria-label']], + active:activeList.children.map(row => [row.name,row.attributes['data-unavailable'] || null]), + planning:planningList.children.map(row => row.name), + all:allList.children.map(row => row.name), activeHidden:activeSection.hidden, +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "next": ["Recover Delivery (1)", "delivery", "Next up: Recover Delivery (1)"], + "active": [["delivery", None], ["gate", None], ["attention", "true"]], + "planning": ["today", "tomorrow", "week"], + "all": ["agenda", "update", "following", "filed", "authored", "later", "draft", "find", "recaps"], + "activeHidden": False, + } + + def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher(): script = f""" const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); From b7d496600e037d2aab9a2b287ce9529f77b38e53 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 07:32:16 +0000 Subject: [PATCH 2/2] test: exercise collapsed all queues --- tests/e2e/test_mobile_authored_pull_queue_release.py | 1 + tests/e2e/test_mobile_home_bootstrap_release.py | 1 + tests/e2e/test_mobile_today_handoff_release.py | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/e2e/test_mobile_authored_pull_queue_release.py b/tests/e2e/test_mobile_authored_pull_queue_release.py index 1c402f0..d2d45cc 100644 --- a/tests/e2e/test_mobile_authored_pull_queue_release.py +++ b/tests/e2e/test_mobile_authored_pull_queue_release.py @@ -28,6 +28,7 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js") page.add_script_tag(path=FRONTEND / "pull-sheet.js") page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()") + page.locator(".mobile-queue-all summary").click() row = page.locator('[data-mobile-queue="authored"]') expect(row).to_have_count(1) diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index c2f7f76..96d462c 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -239,6 +239,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( page.locator("#cancel-plan-today").click() page.locator('[data-mobile-task="queues"]').click() + page.locator(".mobile-queue-all summary").click() delivery_queue = page.locator('[data-mobile-queue="delivery"]') expect(delivery_queue).to_be_visible() expect(delivery_queue).to_contain_text("Delivery") diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index 4f9f662..c68040c 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -416,6 +416,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]") expect(queue_pause).to_be_visible() expect(queue_pause).to_contain_text("Today paused · Ship mobile capture") + page.locator(".mobile-queue-all summary").click() page.locator('[data-mobile-queue="later"]').click() persistent_pause = page.locator("#my-work > [data-today-detour]") expect(persistent_pause).to_be_visible()