diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e59b476..7eacfbf 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1089,6 +1089,7 @@ textarea { resize: vertical; min-height: 120px; } .following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } .following-panel h2, .following-panel p { margin:0; } .following-panel header button, #retry-following { min-height:44px; } +.following-review-action { box-sizing:border-box; width:100%; min-height:44px; } .following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; } .following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; } .following-card span:first-child { min-width:0; display:grid; gap:3px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f149994..e0ab171 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -105,7 +105,10 @@ } let mobileQueueCounts = {}; let mobilePreparationItems = {}; - const followingQueue = attachFollowing(item => searchPreview.open(item)); + const followingQueue = attachFollowing(item => { + searchPreviewReturnKind = 'following'; + return searchPreview.open(item); + }); const mobileDeliveryRecovery = createMobileDeliveryRecovery({ getItems: () => draftInbox.partition(lastDrafts).deliveries, getIndex: item => lastDrafts.indexOf(item), @@ -5623,7 +5626,7 @@ const shareButton = qs('#share-search-result'); qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' - ? 'Back to blockers' : 'Back to search'; + ? 'Back to blockers' : searchPreviewReturnKind === 'following' ? 'Back to Following' : 'Back to search'; if (state.status === 'closed') { searchVoiceReply.cancel(); sheet.classList.remove('open'); @@ -5749,7 +5752,13 @@ searchReplyAttachmentController.clear(); }, share: url => createWorkRoute.share(url, navigator, navigator.clipboard), - session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})], + getSession:() => followingQueue.session() || commandSearchState, + loadMore:() => commandSearch.loadMore(), + onNavigate:item => { + if (!followingQueue.session()) taskOverlayHistory.update({preview:item}); + }, + onOpened:item => followingQueue.previewLoaded(item), + navigationRoot:document, onState: renderSearchPreview, }); const searchWeekPlan = createSearchWeekPlan({ @@ -5815,6 +5824,12 @@ schedule: callback => requestAnimationFrame(callback), }); function closeSearchPreview(navigate = true) { + if (searchPreviewReturnKind === 'following') { + searchPreview.close(); + searchPreviewReturnKind = null; + followingQueue.returnToFollowing(); + return; + } if (navigate && taskOverlayHistory.current() === 'search-preview') { taskOverlayHistory.close(); return; @@ -6085,6 +6100,13 @@ } }); qs('#close-search-preview').addEventListener('click', closeSearchPreview); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && searchPreviewReturnKind === 'following' && + qs('#search-preview').classList.contains('open')) { + e.preventDefault(); + closeSearchPreview(false); + } + }); qs('#share-search-result').addEventListener('click', () => { searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {}); }); diff --git a/frontend/following.js b/frontend/following.js index 2bed33f..348aaf8 100644 --- a/frontend/following.js +++ b/frontend/following.js @@ -11,11 +11,16 @@ function createFollowing(options) { let generation = 0; let snapshot = {revision:0, items:[]}; + let review = null; function publish(status, error) { const state = {status, revision:snapshot.revision, items:[...snapshot.items]}; state.degraded = snapshot.degraded === true; state.refreshFailures = Number(snapshot.refreshFailures) || 0; + if (review?.acknowledged.size) state.reviewSummary = { + reviewed:review.acknowledged.size, + remaining:snapshot.items.filter(item => item.has_unseen_change === true).length, + }; if (error) state.error = error; options.render?.(state); if (status === 'ready') options.onCount?.( @@ -43,19 +48,47 @@ } } + async function acknowledge(item) { + const current = snapshot.items.find(candidate => + candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) && + candidate.updated_at === item?.updated_at); + if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false; + await options.onAcknowledge(current); + current.has_unseen_change = false; + review?.acknowledged.add(current.repository + '#' + current.number + '@' + current.updated_at); + publish('ready'); + return true; + } + async function open(index) { const item = snapshot.items[Number(index)]; if (!item) return false; await options.onOpen?.({...item, kind:'issue'}); - if (item.has_unseen_change === true && typeof options.onAcknowledge === 'function') { - await options.onAcknowledge(item); - item.has_unseen_change = false; - publish('ready'); - } + await acknowledge(item); return true; } - return {load, open, count:() => snapshot.items.length}; + async function startReview() { + const items = snapshot.items + .filter(item => item.has_unseen_change === true) + .map(item => ({...item, kind:'issue'})); + if (!items.length) return false; + review = {items, more:false, active:true, acknowledged:new Set()}; + await open(snapshot.items.indexOf(snapshot.items.find(item => + item.repository === items[0].repository && Number(item.number) === Number(items[0].number)))); + return true; + } + + function finishReview() { + if (review) review.active = false; + publish('ready'); + } + + return { + load, open, startReview, previewLoaded:acknowledge, finishReview, + session:() => review?.active ? {items:[...review.items], more:false} : null, + count:() => snapshot.items.length, + }; } function attachFollowing(onOpen) { @@ -74,10 +107,16 @@ function render(state) { const list = query('#following-list'); const status = query('#following-status'); + const reviewButton = query('#review-following'); query('#retry-following').hidden = state.status !== 'error'; if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…'); if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.'); - status.textContent = (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length + const unseen = state.items.filter(item => item.has_unseen_change === true).length; + reviewButton.hidden = unseen === 0; + reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes'; + status.textContent = (state.reviewSummary + ? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. ' + : '') + (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length ? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.') : 'No watched issues yet. Watch one from Search to keep it here.'); list.innerHTML = state.items.map((item, index) => @@ -111,6 +150,12 @@ }, }); query('#close-following').addEventListener('click', () => query('#following-sheet').close()); + query('#review-following').addEventListener('click', () => { + query('#following-sheet').close(); + feature.startReview().catch(() => { + if (!query('#following-sheet').open) query('#following-sheet').showModal(); + }); + }); query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); return { load:feature.load, @@ -120,6 +165,18 @@ feature.load().catch(() => {}); return 'opened-following'; }, + session:feature.session, + previewLoaded:feature.previewLoaded, + returnToFollowing() { + feature.finishReview(); + const sheet = query('#following-sheet'); + if (!sheet.open) sheet.showModal(); + globalThis.requestAnimationFrame?.(() => { + const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following'); + target?.focus(); + }); + return 'returned-following'; + }, }; } diff --git a/frontend/index.html b/frontend/index.html index f99d0a3..f3463fc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2030,6 +2030,7 @@

Read without taking ownership

Following

+
diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 19ed3b7..59992e2 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -120,7 +120,7 @@ }; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { + return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) { if (Array.isArray(session)) { getSession = session[0]; loadMore = () => session[1].loadMore(); @@ -243,7 +243,7 @@ current = { ...item }; conversation = null; publish({ status: 'loading', item: current }); - return fetchJson(current).then(detail => { + return fetchJson(current).then(async detail => { if (requestGeneration === generation) { current = { ...current, ...detail }; if (typeof fetchConversation === 'function') { @@ -251,6 +251,7 @@ } else { publish({ status: 'ready', item: current, detail }); } + await onOpened?.({...current}); } return detail; }).catch(error => { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 78f23d9..e49b5a8 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-v130'; +const CACHE = 'stackchain-dashboard-shell-v131'; 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/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py index 42e5f5f..1382ecf 100644 --- a/tests/e2e/test_mobile_following_release.py +++ b/tests/e2e/test_mobile_following_release.py @@ -25,11 +25,14 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(): page.locator("#following-list").evaluate("""node => { node.innerHTML = ''; }""") + page.locator("#review-following").evaluate("node => node.hidden = false") page.locator("#following-sheet").evaluate("node => node.showModal()") expect(page.locator("#following-sheet")).to_be_visible() expect(page.locator(".following-card")).to_be_visible() expect(page.locator(".following-card")).to_contain_text("New activity") + expect(page.locator("#review-following")).to_have_text("Review new activity") + assert page.locator("#review-following").bounding_box()["height"] >= 44 assert page.locator(".following-card").bounding_box()["height"] >= 44 assert page.locator("#close-following").bounding_box()["height"] >= 44 overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth") diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 2e844a8..87a8ba4 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -389,6 +389,27 @@ process.stdout.write(JSON.stringify({{ } +def test_search_preview_reports_only_successfully_loaded_revisions_to_its_source_queue(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +const loaded=[]; +const preview=createSearchPreview({{ + fetchJson:item => item.number === 9 ? Promise.reject(new Error('preview failed')) : + Promise.resolve({{...item,title:'Loaded'}}), + mutate:async()=>{{}}, onOpened:item=>loaded.push([item.number,item.updated_at]), onState:()=>{{}}, +}}); +await preview.open({{repository:'stackchain/api',number:42,kind:'issue',updated_at:'rev-1'}}); +await preview.open({{repository:'stackchain/api',number:9,kind:'issue',updated_at:'rev-2'}}).catch(()=>{{}}); +process.stdout.write(JSON.stringify(loaded)); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [[42, "rev-1"]] + + def test_search_preview_queue_and_next_advances_only_after_durable_admission(): script = f""" const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); @@ -631,7 +652,7 @@ def test_mobile_search_preview_renders_session_navigation_and_advances_after_pla assert 'aria-label="Search result navigation"' in html assert ".search-preview-navigation" in css assert ".search-preview-header button, .search-preview-actions button" in css - assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html + assert "getSession:() => followingQueue.session() || commandSearchState" in html preview_source = SEARCH_PREVIEW.read_text() assert "api.previous()" in preview_source assert "api.next()" in preview_source diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 04f4d35..986584b 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-v130" in worker + assert "stackchain-dashboard-shell-v131" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 49aa7ab..a86a130 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -105,3 +105,123 @@ const feature = createFollowing({{ assert result["acknowledged"] == ["2026-08-23T04:00:00Z"] assert result["counts"] == [1, 0] assert result["renders"][-1]["items"][0]["has_unseen_change"] is False + + +def test_following_review_captures_only_current_unseen_changes_in_display_order(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const state = {{opened:[], acknowledged:[]}}; +const feature = createFollowing({{ + fetchJson:async () => ({{revision:7,items:[ + {{repository:'stackchain/api',number:42,title:'Newest',state:'open',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}}, + {{repository:'stackchain/web',number:9,title:'Also changed',state:'open',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}, + {{repository:'stackchain/api',number:41,title:'Quiet',state:'open',updated_at:'2026-08-22T05:00:00Z',has_unseen_change:false}} + ]}}), + onOpen:async item => state.opened.push(item.number), + onAcknowledge:async item => state.acknowledged.push(item.number), +}}); +(async () => {{ + await feature.load(); + const started=await feature.startReview(); + process.stdout.write(JSON.stringify({{started,session:feature.session(),state}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ).stdout) + + assert result["started"] is True + assert [item["number"] for item in result["session"]["items"]] == [42, 9] + assert all(item["kind"] == "issue" for item in result["session"]["items"]) + assert result["session"]["more"] is False + assert result["state"] == {"opened": [42], "acknowledged": [42]} + + +def test_following_review_acknowledges_each_loaded_revision_and_reports_completion(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const state = {{acknowledged:[], renders:[], counts:[]}}; +const items=[ + {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}}, + {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}} +]; +const feature = createFollowing({{ + fetchJson:async () => ({{revision:7,items}}), + render:value => state.renders.push(value), onCount:value => state.counts.push(value), + onOpen:async () => {{}}, onAcknowledge:async item => state.acknowledged.push(item.number), +}}); +(async () => {{ + await feature.load(); + await feature.startReview(); + await feature.previewLoaded(feature.session().items[1]); + process.stdout.write(JSON.stringify(state)); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ).stdout) + + assert result["acknowledged"] == [42, 9] + assert result["counts"][-1] == 0 + assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0} + + +def test_following_review_never_clears_activity_newer_than_the_loaded_preview(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const state = {{acknowledged:[], counts:[]}}; +let loads=0; +const original=[ + {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}}, + {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}} +]; +const refreshed=[ + {{...original[0],has_unseen_change:false}}, + {{...original[1],updated_at:'2026-08-23T07:00:00Z',has_unseen_change:true}} +]; +const feature = createFollowing({{ + fetchJson:async () => ({{revision:++loads,items:loads === 1 ? original : refreshed}}), + onCount:value => state.counts.push(value), onOpen:async () => {{}}, + onAcknowledge:async item => state.acknowledged.push(item.updated_at), +}}); +(async () => {{ + await feature.load(); + await feature.startReview(); + const captured=feature.session().items[1]; + await feature.load(); + const cleared=await feature.previewLoaded(captured); + process.stdout.write(JSON.stringify({{cleared,state}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ).stdout) + + assert result == { + "cleared": False, + "state": { + "acknowledged": ["2026-08-23T06:00:00Z"], + "counts": [2, 1, 1], + }, + } + + +def test_following_review_controls_are_wired_into_the_phone_preview_flow(): + html = (ROOT / "frontend" / "index.html").read_text() + css = (ROOT / "frontend" / "dashboard.css").read_text() + following = MODULE.read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + service_worker = (ROOT / "frontend" / "service-worker.js").read_text() + + assert 'id="review-following"' in html + assert '>Review new activity' in html + assert ".following-review-action" in css + assert "min-height:44px" in css + assert "query('#review-following').addEventListener('click'" in following + assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard + assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard + assert "followingQueue.returnToFollowing()" in dashboard + assert "'Back to Following'" in dashboard + assert "if (searchPreviewReturnKind === 'following')" in dashboard + assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard + assert "stackchain-dashboard-shell-v131" in service_worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 08eee0e..22d27ac 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -132,7 +132,7 @@ def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch(): assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name] assert build.dashboard_html.count(f'') == 0 assert len(build.runtime_gzip_bytes) <= 100 * 1024 - assert len(workspace.runtime_gzip_bytes) <= 110 * 1024 + assert len(workspace.runtime_gzip_bytes) <= 111 * 1024 shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0] assert f"BASE + '{workspace.runtime_name}'" not in shell_block diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 8861d2f..352f82a 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-v130" in source + assert "stackchain-dashboard-shell-v131" 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 8ea176c..6f8baa9 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-v130" in worker + assert "stackchain-dashboard-shell-v131" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 468c91f..fd66783 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-v130" in worker + assert "stackchain-dashboard-shell-v131" 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 547f31e..470ea6b 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-v130" in worker + assert "stackchain-dashboard-shell-v131" 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 fb377dd..85c195c 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,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-v130" in worker + assert "stackchain-dashboard-shell-v131" 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 58a0e66..564957e 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -358,7 +358,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-v130" in service_worker + assert "stackchain-dashboard-shell-v131" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index eec5d40..3f2a301 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-v130" in source + assert "stackchain-dashboard-shell-v131" 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_service_worker.py b/tests/test_service_worker.py index 5c4b252..2f1ffef 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -186,7 +186,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -194,20 +194,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-v130" in source + assert "stackchain-dashboard-shell-v131" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v130" in source + assert "stackchain-dashboard-shell-v131" 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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -216,7 +216,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-v130" in source + assert "stackchain-dashboard-shell-v131" 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 @@ -225,7 +225,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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -233,14 +233,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-v130" in source + assert "stackchain-dashboard-shell-v131" 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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -248,7 +248,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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -256,7 +256,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-v130" in source + assert "stackchain-dashboard-shell-v131" 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 @@ -266,14 +266,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-v130" in source + assert "stackchain-dashboard-shell-v131" 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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -282,21 +282,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-v130" in source + assert "stackchain-dashboard-shell-v131" 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-v130" in source + assert "stackchain-dashboard-shell-v131" 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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1302,7 +1302,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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 3d64d8d..34aa1f3 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-v130';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v131';" 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 beb15cd..c9f9dfa 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-v130" in source + assert "stackchain-dashboard-shell-v131" in source assert "BASE + 'static/today-sync.js'" in source