From 62de2e6011922d02c639036ed0044897f2f7bcc4 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 10:18:44 +0000 Subject: [PATCH 1/2] feat: make mobile dock progressive (Closes #1427) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 8 + frontend/index.html | 1 + frontend/progressive-mobile-dock.js | 119 +++++++++++ frontend/progressive-my-work.js | 33 ++++ frontend/service-worker.js | 3 +- src/frontend_bundle.py | 2 +- .../test_progressive_mobile_dock_release.py | 60 ++++++ tests/test_comment_next.py | 2 +- tests/test_following_frontend.py | 2 +- tests/test_human_gates_frontend.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_progressive_mobile_dock.py | 184 ++++++++++++++++++ tests/test_service_worker.py | 35 ++-- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 22 files changed, 439 insertions(+), 31 deletions(-) create mode 100644 frontend/progressive-mobile-dock.js create mode 100644 tests/e2e/test_progressive_mobile_dock_release.py create mode 100644 tests/test_progressive_mobile_dock.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5bc91c8..ff71d07 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1521,6 +1521,7 @@ textarea { resize: vertical; min-height: 120px; } .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-queue-list [data-progressive-loading="true"]::after { content:'Still loading · available after workspace starts'; } .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 6fab9be..2226a31 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4,6 +4,8 @@ const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.(); const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.(); const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.(); + const progressiveMobileDockHandoff = window.stackchainProgressiveMobileDock?.handoff?.(); + window.stackchainProgressiveMobileDock?.stop?.(); window.stackchainProgressiveMyWork?.stop(); const qs = (s, el=document) => el.querySelector(s); const announceWork = message => qs('#my-work-action-status').textContent = message; @@ -307,6 +309,12 @@ }, }); mobileTaskDock.start(); + if (['work', 'queues'].includes(progressiveMobileDockHandoff?.lastTask)) { + mobileTaskDock.select(progressiveMobileDockHandoff.lastTask); + } + if (progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open) { + qs('#mobile-queue-sheet').showModal(); + } const mobileTodayActions = qs('#mobile-today-actions'); createMobileTodayCommandBar({ more:qs('[data-mobile-today-more]'), diff --git a/frontend/index.html b/frontend/index.html index bfba1d9..22585a0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2325,6 +2325,7 @@ + diff --git a/frontend/progressive-mobile-dock.js b/frontend/progressive-mobile-dock.js new file mode 100644 index 0000000..5ddd90c --- /dev/null +++ b/frontend/progressive-mobile-dock.js @@ -0,0 +1,119 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createProgressiveMobileDock = factory; +})(typeof self !== 'undefined' ? self : this, function createProgressiveMobileDock(options) { + const document = options.document; + const myWork = options.myWork; + const work = document.querySelector('[data-mobile-task="work"]'); + const queues = document.querySelector('[data-mobile-task="queues"]'); + const sheet = document.querySelector('#mobile-queue-sheet'); + const close = document.querySelector('#close-mobile-queues'); + const next = document.querySelector('#mobile-queue-next-action'); + const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]')) + .map(row => [row.dataset.mobileQueue, row])); + const countElements = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue-count]')) + .map(element => [element.dataset.mobileQueueCount, element])); + const originalDescriptions = Object.fromEntries(Object.entries(rows) + .map(([name, row]) => [name, row.querySelector?.('small')?.textContent || ''])); + const originalCounts = Object.fromEntries(Object.entries(countElements) + .map(([name, element]) => [name, element.textContent])); + const supported = new Set(['all', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update']); + const listeners = []; + let started = false; + let lastTask = null; + let unsubscribe = null; + + function listen(element, name, listener) { + if (!element) return; + element.addEventListener(name, listener); + listeners.push([element, name, listener]); + } + + function render() { + const counts = myWork?.counts?.() || {}; + Object.entries(rows).forEach(([name, row]) => { + const count = Math.max(0, Number(counts[name]) || 0); + const countElement = countElements[name]; + if (supported.has(name)) { + row.removeAttribute('data-unavailable'); + row.removeAttribute('data-progressive-loading'); + if (countElement) countElement.textContent = String(count); + return; + } + row.setAttribute('data-unavailable', 'true'); + row.setAttribute('data-progressive-loading', 'true'); + const description = row.querySelector?.('small'); + if (description) description.textContent = 'Still loading'; + if (countElement) countElement.textContent = '…'; + }); + const actionable = ['attention', 'filed', 'authored', 'review', 'update'] + .find(name => Number(counts[name]) > 0); + if (next) { + if (actionable) { + next.textContent = 'Open ' + (actionable === 'authored' ? 'My PRs' : actionable[0].toUpperCase() + actionable.slice(1)) + + ' (' + counts[actionable] + ')'; + next.dataset.queue = actionable; + next.removeAttribute('data-unavailable'); + } else { + next.textContent = Number(counts.all) > 0 ? 'Open assigned work (' + counts.all + ')' : 'Assigned work is still loading'; + next.dataset.queue = 'all'; + if (!Number(counts.all)) next.setAttribute('data-unavailable', 'true'); + } + } + } + + function openSheet() { + lastTask = 'queues'; + render(); + if (sheet && !sheet.open) sheet.showModal(); + } + + function openQueue(name) { + if (!supported.has(name)) return false; + lastTask = 'work'; + if (sheet?.open) sheet.close(); + myWork?.selectQueue?.(name, {openFirst:true}); + return true; + } + + function start() { + if (started) return false; + started = true; + listen(work, 'click', () => { lastTask = 'work'; myWork?.openFirst?.(); }); + listen(queues, 'click', openSheet); + listen(close, 'click', () => sheet?.open && sheet.close()); + Object.entries(rows).forEach(([name, row]) => listen(row, 'click', () => openQueue(name))); + listen(next, 'click', () => openQueue(next.dataset.queue || 'all')); + unsubscribe = myWork?.subscribe?.(render) || null; + render(); + return true; + } + + function handoff() { + return {queueSheetOpen:Boolean(sheet?.open), lastTask}; + } + + function stop() { + listeners.splice(0).forEach(([element, name, listener]) => element.removeEventListener(name, listener)); + unsubscribe?.(); + unsubscribe = null; + Object.entries(rows).forEach(([name, row]) => { + row.removeAttribute('data-unavailable'); + row.removeAttribute('data-progressive-loading'); + const description = row.querySelector?.('small'); + if (description) description.textContent = originalDescriptions[name]; + if (countElements[name]) countElements[name].textContent = originalCounts[name]; + }); + started = false; + } + + return {start, stop, handoff, render, openQueue}; +}); + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + window.stackchainProgressiveMobileDock = createProgressiveMobileDock({ + document, + myWork:window.stackchainProgressiveMyWork, + }); + window.stackchainProgressiveMobileDock.start(); +} diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js index a0b5867..6ea364d 100644 --- a/frontend/progressive-my-work.js +++ b/frontend/progressive-my-work.js @@ -13,6 +13,7 @@ function createProgressiveMyWork({ const filters = Array.from(document.querySelectorAll('[data-work-filter]')); const listeners = []; const lifecycleListeners = []; + const subscribers = new Set(); let items = []; let active = 'all'; let stopped = false; @@ -47,6 +48,15 @@ function createProgressiveMyWork({ filter === 'authored' ? item.is_authored : filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter; const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active)); + const notify = () => subscribers.forEach(subscriber => subscriber()); + const queueCounts = () => ({ + all:items.length, + filed:items.filter(item => matches(item, 'filed')).length, + authored:items.filter(item => matches(item, 'authored')).length, + attention:items.filter(item => matches(item, 'attention')).length, + update:items.filter(item => matches(item, 'update')).length, + review:items.filter(item => matches(item, 'review')).length, + }); const closeProgressiveDetail = ({ restoreFocus = true } = {}) => { if (!detail || detail.hidden) return; detail.hidden = true; @@ -124,6 +134,7 @@ function createProgressiveMyWork({ items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); updateCounts(); render(); + notify(); const assigned = items.filter(item => item.is_assigned).length; if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.'; return true; @@ -150,6 +161,27 @@ function createProgressiveMyWork({ return { login() { return confirmedLogin; }, + counts() { return queueCounts(); }, + subscribe(subscriber) { + subscribers.add(subscriber); + return () => subscribers.delete(subscriber); + }, + openFirst() { + const item = visibleItems()[0]; + if (!item) return deferredQueues[active] ? 'loading' : 'empty'; + const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null; + openProgressiveDetail(item, trigger); + return 'opened'; + }, + selectQueue(name, {openFirst = false} = {}) { + const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']); + if (!allowed.has(name)) return 'unsupported'; + active = name; + selectedByUser = true; + render(); + notify(); + return openFirst ? this.openFirst() : 'selected'; + }, handoff() { const state = { selectedFilter:selectedByUser ? active : null }; if (openWork) { @@ -204,6 +236,7 @@ function createProgressiveMyWork({ lifecycleTarget.removeEventListener?.('keydown', keyListener); lifecycleListeners.forEach(([eventName, listener]) => lifecycleTarget.removeEventListener?.(eventName, listener)); + subscribers.clear(); }, }; } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 67f6c2f..8fd3e4d 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v142'; +const CACHE = 'stackchain-dashboard-shell-v143'; 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; @@ -190,6 +190,7 @@ const SHELL = [ BASE + 'static/my-work.js', BASE + 'static/progressive-live-snapshot.js', BASE + 'static/progressive-my-work.js', + BASE + 'static/progressive-mobile-dock.js', BASE + 'static/progressive-capture.js', BASE + 'static/agenda-replan.js', BASE + 'static/agenda-calendar.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 7485aa1..6fb5de6 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -22,7 +22,7 @@ COMMONJS_BROWSER_BRANCH = re.compile( WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { "work-core": ( - "static/my-work.js", "static/progressive-my-work.js", + "static/my-work.js", "static/progressive-my-work.js", "static/progressive-mobile-dock.js", "static/unfiled-captures.js", "static/progressive-capture.js", ), "comment-actions": ("static/comment-actions.js",), diff --git a/tests/e2e/test_progressive_mobile_dock_release.py b/tests/e2e/test_progressive_mobile_dock_release.py new file mode 100644 index 0000000..8a96957 --- /dev/null +++ b/tests/e2e/test_progressive_mobile_dock_release.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest +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": 390, "height": 844}]) +def test_mobile_work_and_queues_are_usable_before_optional_hydration(viewport): + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + 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 / "my-work.js") + page.add_script_tag(path=FRONTEND / "context-poller.js") + page.evaluate( + """() => { + window.fetch = async () => ({ + ok:true, headers:{get:()=>null}, json:async()=>({ + context:{ + user:{login:'timmy'}, + issues:[{ + number:1427,title:'Progressive mobile dock',repository:'stackchain/stackchain-dashboard', + assignees:['timmy'],work_reasons:['created_by_me'],url:'https://forge.example/issues/1427', + }], + pull_requests:[], + }, + events:[],notifications:[], + }), + }); + }""" + ) + page.add_script_tag(path=FRONTEND / "progressive-my-work.js") + expect(page.locator("#my-work-status")).to_contain_text("1 assigned work item") + page.add_script_tag(path=FRONTEND / "progressive-mobile-dock.js") + + page.locator('[data-mobile-task="queues"]').click() + expect(page.locator("#mobile-queue-sheet")).to_be_visible() + expect(page.locator('[data-mobile-queue="filed"] [data-mobile-queue-count]')).to_have_text("1") + page.locator(".mobile-queue-all summary").click() + expect(page.locator('[data-mobile-queue="delivery"]')).to_contain_text("Still loading") + next_action = page.locator("#mobile-queue-next-action") + bounds = next_action.bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + page.locator('[data-mobile-queue="filed"]').click() + expect(page.locator("#progressive-work-detail")).to_be_visible() + expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock") + page.locator("#close-progressive-work-detail").click() + + page.locator('[data-mobile-task="work"]').click() + expect(page.locator("#progressive-work-detail")).to_be_visible() + expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock") + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + browser.close() diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index b4c4166..058ab45 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-v142" in worker + assert "stackchain-dashboard-shell-v143" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index b796837..8894d53 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{ assert ".following-disposition-mode" in css assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard - assert "stackchain-dashboard-shell-v142" in service_worker + assert "stackchain-dashboard-shell-v143" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py index 551fce6..d212956 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -262,7 +262,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired(): assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard assert "counts.gate = queueCounts.gate" in dashboard assert "gate:preparationItems.gate || []" in dashboard - assert "stackchain-dashboard-shell-v142" in WORKER.read_text() + assert "stackchain-dashboard-shell-v143" in WORKER.read_text() def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace(): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 21079d4..a9808e4 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,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-v142" in source + assert "stackchain-dashboard-shell-v143" 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 b7f4cea..9255b3c 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,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-v142" in worker + assert "stackchain-dashboard-shell-v143" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 83d38fb..8e90622 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-v142" in worker + assert "stackchain-dashboard-shell-v143" 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 b18a7e8..ba18263 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v142" in worker + assert "stackchain-dashboard-shell-v143" 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_mobile_insights.py b/tests/test_mobile_insights.py index 5da50f4..f829b16 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v142" in worker + assert "stackchain-dashboard-shell-v143" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 249f030..aee85c3 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v142" in service_worker + assert "stackchain-dashboard-shell-v143" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 3ae26f4..f8fd3e9 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_progressive_mobile_dock.py b/tests/test_progressive_mobile_dock.py new file mode 100644 index 0000000..a9317fd --- /dev/null +++ b/tests/test_progressive_mobile_dock.py @@ -0,0 +1,184 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +MODULE = ROOT / "frontend" / "progressive-mobile-dock.js" +INDEX = ROOT / "frontend" / "index.html" +DASHBOARD = ROOT / "frontend" / "dashboard.js" +CSS = ROOT / "frontend" / "dashboard.css" +MY_WORK = ROOT / "frontend" / "my-work.js" +PROGRESSIVE_MY_WORK = ROOT / "frontend" / "progressive-my-work.js" + + +def test_progressive_mobile_dock_opens_work_and_truthful_queues_before_hydration(): + harness = f""" +const createDock=require({json.dumps(str(MODULE))}); +function element(dataset={{}}) {{ + return {{dataset,attrs:{{}},listeners:{{}},open:false,textContent:'',focusCount:0, + addEventListener(name,cb){{this.listeners[name]=cb;}}, + removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}}, + setAttribute(name,value){{this.attrs[name]=String(value);}}, + removeAttribute(name){{delete this.attrs[name];}}, + showModal(){{this.open=true;}},close(){{this.open=false;}},focus(){{this.focusCount+=1;}}, + }}; +}} +const work=element({{mobileTask:'work'}}); const queues=element({{mobileTask:'queues'}}); +const sheet=element(); const close=element(); const next=element(); +const rows=Object.fromEntries(['today','agenda','delivery','gate','attention','update','filed','later','draft','authored'] + .map(name=>[name,element({{mobileQueue:name}})])); +const counts=Object.fromEntries(Object.keys(rows).map(name=>[name,element({{mobileQueueCount:name}})])); +const smalls=Object.fromEntries(Object.keys(rows).map(name=>[name,{{textContent:'source label'}}])); +Object.keys(rows).forEach(name=>rows[name].querySelector=selector=>selector==='small'?smalls[name]:null); +const document={{ + querySelector(selector){{return {{ + '#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next, + '[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues, + }}[selector]||null;}}, + querySelectorAll(selector){{ + if(selector==='[data-mobile-queue]')return Object.values(rows); + if(selector==='[data-mobile-queue-count]')return Object.values(counts); + return []; + }}, +}}; +const calls=[]; +const myWork={{ + counts:()=>({{all:3,filed:1,authored:1,attention:1,update:0}}), + openFirst:()=>calls.push(['openFirst']), + selectQueue:(name,options)=>calls.push(['selectQueue',name,options]), +}}; +const dock=createDock({{document,myWork}}); dock.start(); +queues.listeners.click({{currentTarget:queues}}); +const opened=sheet.open; const initialHandoff=dock.handoff(); +rows.filed.listeners.click(); +work.listeners.click({{currentTarget:work}}); +const known={{filed:counts.filed.textContent,attention:counts.attention.textContent}}; +const loading={{ + deferred:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent,gate:smalls.gate.textContent,later:smalls.later.textContent}}, + unavailable:{{today:rows.today.attrs['data-unavailable'],delivery:rows.delivery.attrs['data-unavailable']}}, +}}; +dock.stop(); +console.log(JSON.stringify({{ + opened,initialHandoff,calls,loading,known, + restored:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent}}, + cleaned:!('data-unavailable' in rows.today.attrs) && !('data-progressive-loading' in rows.today.attrs), + listeners:{{work:Object.keys(work.listeners),queues:Object.keys(queues.listeners),filed:Object.keys(rows.filed.listeners)}}, +}})); +""" + result = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "opened": True, + "initialHandoff": {"queueSheetOpen": True, "lastTask": "queues"}, + "calls": [ + ["selectQueue", "filed", {"openFirst": True}], + ["openFirst"], + ], + "known": {"filed": "1", "attention": "1"}, + "loading": { + "deferred": { + "today": "Still loading", + "delivery": "Still loading", + "gate": "Still loading", + "later": "Still loading", + }, + "unavailable": {"today": "true", "delivery": "true"}, + }, + "restored": {"today": "source label", "delivery": "source label"}, + "cleaned": True, + "listeners": {"work": [], "queues": [], "filed": []}, + } + + +def test_progressive_mobile_dock_refreshes_counts_from_live_work_and_unsubscribes_on_handoff(): + harness = f""" +const createDock=require({json.dumps(str(MODULE))}); +function element(dataset={{}}) {{return {{dataset,textContent:'',listeners:{{}},attrs:{{}},open:false, + addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(name){{delete this.listeners[name];}}, + setAttribute(name,value){{this.attrs[name]=String(value);}},removeAttribute(name){{delete this.attrs[name];}}, + querySelector:()=>({{textContent:''}}),showModal(){{this.open=true;}},close(){{this.open=false;}}, +}};}} +const work=element({{mobileTask:'work'}}),queues=element({{mobileTask:'queues'}}),sheet=element(),close=element(),next=element(); +const filed=element({{mobileQueue:'filed'}}),filedCount=element({{mobileQueueCount:'filed'}}); +const document={{querySelector:s=>({{ + '[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues, + '#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next, +}}[s]||null),querySelectorAll:s=>s==='[data-mobile-queue]'?[filed]:s==='[data-mobile-queue-count]'?[filedCount]:[]}}; +let counts={{all:0,filed:0}}, subscriber=null, unsubscribed=false; +const myWork={{counts:()=>counts,subscribe(cb){{subscriber=cb;return()=>{{unsubscribed=true;subscriber=null;}};}},openFirst(){{}},selectQueue(){{}}}}; +const dock=createDock({{document,myWork}}); dock.start(); +const before=filedCount.textContent; counts={{all:2,filed:2}}; subscriber(); const after=filedCount.textContent; +dock.stop(); +console.log(JSON.stringify({{before,after,unsubscribed,subscriber:subscriber===null}})); +""" + result = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "before": "0", "after": "2", "unsubscribed": True, "subscriber": True, + } + + +def test_progressive_mobile_dock_is_shipped_before_optional_workspace_hydration(): + index = INDEX.read_text() + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + + assert '' in index + assert index.index('static/progressive-my-work.js') < index.index('static/progressive-mobile-dock.js') + assert index.index('static/progressive-mobile-dock.js') < index.index('static/dashboard.js') + assert "window.stackchainProgressiveMobileDock?.handoff?.()" in dashboard + assert "window.stackchainProgressiveMobileDock?.stop?.()" in dashboard + assert "mobileTaskDock.select(progressiveMobileDockHandoff.lastTask)" in dashboard + assert "progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open" in dashboard + assert '[data-progressive-loading="true"]::after' in css + assert "content:'Still loading · available after workspace starts'" in css + + +def test_progressive_my_work_exposes_counts_and_opens_the_first_selected_queue_item(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +function element(extra={{}}) {{return Object.assign({{ + innerHTML:'',textContent:'',hidden:true,dataset:{{}},listeners:{{}},focusCount:0, + addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(){{}}, + setAttribute(){{}},removeAttribute(){{}},focus(){{this.focusCount+=1;}}, +}},extra);}} +const list=element(); const status=element(); const detail=element(); +const title=element(); const meta=element(); const reason=element(); const close=element(); const link=element(); +const elements={{'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':detail, + '#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta, + '#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close, + '#open-progressive-work-gitea':link}}; +const buttons=['all','filed','authored','attention'].map(name=>element({{dataset:{{workFilter:name}}}})); +const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>buttons}}; +const lifecycleTarget=element(); +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(PROGRESSIVE_MY_WORK))},'utf8'),context); +const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{ + user:{{login:'timmy'}}, + issues:[{{number:7,title:'Filed issue',repository:'stackchain/dashboard',work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}], + pull_requests:[{{number:8,title:'Authored PR',repository:'stackchain/dashboard',work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}], +}})}}); +(async()=>{{ + let updates=0; const unsubscribe=flow.subscribe(()=>{{updates+=1;}}); + await flow.start(); const counts=flow.counts(); + const selected=flow.selectQueue('filed',{{openFirst:true}}); const handoff=flow.handoff(); + unsubscribe(); flow.selectQueue('authored'); + console.log(JSON.stringify({{counts,updates,selected,detailHidden:detail.hidden,title:title.textContent,handoff}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + state = json.loads(result.stdout) + assert state["counts"] == {"all": 2, "filed": 1, "authored": 1, "attention": 0, "update": 0, "review": 0} + assert state["updates"] == 2 + assert state["selected"] == "opened" + assert state["detailHidden"] is False + assert state["title"] == "Filed issue" + assert state["handoff"]["selectedFilter"] == "filed" + assert state["handoff"]["openWork"]["key"] == "stackchain/dashboard#7" diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 5921394..e6fe7c8 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -189,14 +189,14 @@ async function dispatchPush(payload) {{ def test_shared_progressive_snapshot_broker_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/progressive-live-snapshot.js'" in source def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/week-plan.js'" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1361,7 +1361,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/queue-today.js'" in source @@ -1419,6 +1419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/my-work.js", "/dashboard/static/progressive-live-snapshot.js", "/dashboard/static/progressive-my-work.js", + "/dashboard/static/progressive-mobile-dock.js", "/dashboard/static/progressive-capture.js", "/dashboard/static/agenda-replan.js", "/dashboard/static/agenda-calendar.js", diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 8c0b7ed..d038d66 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v142';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v143';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index f946712..df09e48 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v142" in source + assert "stackchain-dashboard-shell-v143" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0 From 664317212dafabee265b4901c60f3cf958fcbfb3 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 10:28:27 +0000 Subject: [PATCH 2/2] test: gate progressive dock browser journey --- tests/e2e/test_progressive_mobile_dock_release.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/test_progressive_mobile_dock_release.py b/tests/e2e/test_progressive_mobile_dock_release.py index 8a96957..fcf1217 100644 --- a/tests/e2e/test_progressive_mobile_dock_release.py +++ b/tests/e2e/test_progressive_mobile_dock_release.py @@ -1,6 +1,11 @@ +import os from pathlib import Path import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("progressive mobile dock checks run only in the browser gate", allow_module_level=True) +pytest.importorskip("playwright.sync_api") from playwright.sync_api import expect, sync_playwright -- 2.43.0