Retire closed issues during Following review #1306

Merged
rockachopa merged 2 commits from timmy/1305-retire-closed-following into main 2026-08-23 12:36:08 +00:00
21 changed files with 295 additions and 46 deletions

View File

@ -113,8 +113,11 @@ including work already assigned to you or a teammate. This completes the Search
flow without changing ownership or scheduling work. Following is a read-first,
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues,
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed
**Stop watching** removes it, while failed or unconfirmed Gitea mutations leave the collection
unchanged. Following counts never influence the recommended Work queue. Set
**Stop watching** removes an open item. When watched work closes, the sequential review exposes
**Stop watching & next** so the completed item can be retired without leaving the preview; the next
captured change opens immediately, and retiring the final item completes the Following phase. Failed
or unconfirmed Gitea mutations leave the collection and current review position unchanged. Following
counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.

View File

@ -5774,6 +5774,7 @@
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
},
onOpened:item => followingQueue.previewLoaded(item),
afterUnwatch:item => followingQueue.retire(item),
navigationRoot:document,
onState: renderSearchPreview,
});

View File

@ -63,7 +63,7 @@
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, kind:'issue'});
await options.onOpen?.({...item, kind:'issue', following:true});
await acknowledge(item);
return true;
}
@ -71,7 +71,7 @@
async function startReview() {
const items = snapshot.items
.filter(item => item.has_unseen_change === true)
.map(item => ({...item, kind:'issue'}));
.map(item => ({...item, kind:'issue', following:true}));
if (!items.length) return false;
review = {items, more:false, active:true, acknowledged:new Set()};
await open(snapshot.items.indexOf(snapshot.items.find(item =>
@ -80,8 +80,8 @@
}
function finishReview() {
const completed = Boolean(review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true));
const completed = Boolean(review?.completed || (review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true)));
if (review) review.active = false;
if (completed && !review.completed) {
review.completed = true;
@ -91,8 +91,30 @@
return completed;
}
function retire(item) {
const same = candidate => candidate.repository === item?.repository &&
Number(candidate.number) === Number(item?.number);
const index = review?.active ? review.items.findIndex(same) : -1;
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
if (index < 0) {
publish('ready');
return null;
}
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) {
review.active = false;
if (!review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
}
publish('ready');
return next ? {...next} : null;
}
return {
load, open, startReview, previewLoaded:acknowledge, finishReview,
load, open, startReview, previewLoaded:acknowledge, finishReview, retire,
session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length,
@ -179,6 +201,7 @@
},
session:feature.session,
previewLoaded:feature.previewLoaded,
retire:feature.retire,
returnToFollowing() {
const completed = feature.finishReview();
if (completed) return 'completed-following';

View File

@ -34,8 +34,12 @@
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => ({
root.searchPreviewSubscriptionOptions = fetchJson => {
const options = {
load:async detail => {
if (detail.kind === 'issue' && detail.state === 'closed' && detail.following === true) {
return {...detail, watching:true};
}
if (!(detail.kind === 'issue' && detail.state === 'open')) return detail;
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
return {...detail, watching:result.watching === true};
@ -43,7 +47,12 @@
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
}),
});
};
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
headers:{Accept:'application/json'},
})});
return options;
};
root.searchPreviewWatchStatus = state => ({
watching:'Starting watch…', unwatching:'Stopping watch…',
watched:'Watching · available in Following. Future activity will appear in Updates.',
@ -52,8 +61,11 @@
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
})[state.status] || '';
root.renderSearchPreviewWatch = (detail, state, button) => {
button.hidden = !(detail.kind === 'issue' && detail.state === 'open');
button.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
const retiring = detail.kind === 'issue' && detail.state === 'closed' &&
detail.following === true && detail.watching === true;
button.hidden = !(detail.kind === 'issue' && detail.state === 'open') && !retiring;
button.textContent = retiring ? 'Stop watching & next' :
(detail.watching ? 'Stop watching' : 'Watch issue');
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
@ -120,7 +132,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, onOpened, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@ -385,11 +397,18 @@
}
const detail = current;
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
watchRequest = watch(detail, watching).then(result => {
watchRequest = watch(detail, watching).then(async result => {
current = { ...current, watching:result?.watching === true };
const status = result?.following_synced === false
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
publish({ status, item:current, detail:current, result });
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
const next = await afterUnwatch({...current});
if (next) {
onNavigate?.(next);
await api.open(next);
}
}
return result;
}).catch(error => {
publish({ status:'watch-error', item:current, detail, error });

View File

@ -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-v133';
const CACHE = 'stackchain-dashboard-shell-v134';
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;

View File

@ -3738,7 +3738,7 @@ async def global_search_preview(
async def _search_preview_subscription_target(
owner: str, repo: str, number: int, kind: str
owner: str, repo: str, number: int, kind: str, *, allow_closed: bool = False
) -> tuple[str, dict]:
repository = f"{owner}/{repo}"
preview = await gitea_proxy.work_preview(repository, kind, number)
@ -3747,7 +3747,7 @@ async def _search_preview_subscription_target(
or preview.get("repository") != repository
or preview.get("kind") != "issue"
or preview.get("number") != number
or preview.get("state") != "open"
or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"})
):
raise HTTPException(status_code=404, detail="Watchable search result not found")
return repository, preview
@ -3790,7 +3790,9 @@ async def mutate_global_search_preview_subscription(
) -> JSONResponse:
watching = request.method == "PUT"
try:
repository, preview = await _search_preview_subscription_target(owner, repo, number, kind)
repository, preview = await _search_preview_subscription_target(
owner, repo, number, kind, allow_closed=not watching
)
login = await _confirmed_login()
store = _following_store()
following_item = {

View File

@ -19,6 +19,7 @@ def test_following_queue_is_phone_usable_at_narrow_viewport():
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1)
@ -35,6 +36,22 @@ def test_following_queue_is_phone_usable_at_narrow_viewport():
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
retire = page.locator("#watch-search-result")
page.evaluate("""() => {
document.querySelector('#following-sheet').close();
document.querySelector('#search-preview').classList.add('open');
const button = document.querySelector('#watch-search-result');
renderSearchPreviewWatch(
{repository:'stackchain/api', number:42, kind:'issue', state:'closed',
following:true, watching:true},
{status:'ready'},
button
);
}""")
expect(retire).to_be_visible()
expect(retire).to_have_text("Stop watching & next")
assert retire.bounding_box()["height"] >= 44
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False
browser.close()

View File

@ -901,7 +901,7 @@ def test_mobile_search_preview_exposes_touch_safe_watch_action():
assert ".search-preview-actions button" in css and "min-height:44px" in css
def test_search_preview_watch_is_available_for_every_open_issue_but_not_pulls_or_closed_issues():
def test_search_preview_watch_is_available_for_open_issues_and_closed_following_retirement():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
@ -925,7 +925,10 @@ for (const detail of [
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
excluded.push(candidate.hidden);
}}
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded}}));
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
const retire = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
@ -942,6 +945,98 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
"disabled": False,
}
assert payload["excluded"] == [True, True]
assert payload["closedFollowing"]["watching"] is True
assert payload["retire"] == {
"hidden": False,
"textContent": "Stop watching & next",
"disabled": False,
}
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let finish;
const states=[]; const retired=[]; const opened=[]; let calls=0;
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
const preview=createSearchPreview({{
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
afterUnwatch:item => {{ retired.push(item.number); return next; }},
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
}});
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
const first=preview.setWatching(false); const second=preview.setWatching(false);
await new Promise(resolve => setImmediate(resolve));
const before={{calls,retired:[...retired],opened:[...opened]}};
finish({{watching:false,following_synced:true}});
await Promise.all([first,second]);
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
}})().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
payload = json.loads(result.stdout)
assert payload["before"] == {"calls": 1, "retired": [], "opened": []}
assert payload["same"] is True
assert payload["retired"] == [42]
assert payload["opened"] == [9]
assert payload["last"]["detail"]["number"] == 9
def test_search_subscription_preview_hydrates_detail_before_watch_state():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls=[];
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
calls.push(path);
if (path.includes('/subscription')) return {{watching:true}};
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
}});
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
process.stdout.write(JSON.stringify({{calls,detail}}));
}})().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) == {
"calls": [
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
],
"detail": {
"repository": "stackchain/api", "number": 42, "kind": "issue",
"state": "open", "title": "Hydrated", "watching": True,
},
}
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls=[];
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
calls.push(path);
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
}});
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
process.stdout.write(JSON.stringify({{calls,detail}}));
}})().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
payload = json.loads(result.stdout)
assert payload["calls"] == [
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
]
assert payload["detail"]["following"] is True
assert payload["detail"]["watching"] is True
def test_search_preview_shares_canonical_url_without_closing_the_preview():

View File

@ -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-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker

View File

@ -249,3 +249,49 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch
assert response.status_code == 200
assert response.json()["items"][0]["has_unseen_change"] is False
@pytest.mark.anyio
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
closed = {
"repository": "stackchain/api", "number": 42, "title": "Finished work",
"state": "closed", "updated_at": "2026-08-23T04:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
store.set_watching("timmy", closed, True)
mutations = []
async def preview(repository, kind, number):
return {**closed, "kind": "issue"}
async def set_subscription(repository, number, watching):
mutations.append((repository, number, watching))
return {"watching": watching}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
watch = await client.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
unwatch = await client.delete(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert watch.status_code == 404
assert unwatch.status_code == 200
assert unwatch.json() == {
"watching": False,
"following_synced": True,
"following_revision": 2,
"following_count": 0,
}
assert mutations == [("stackchain/api", 42, False)]
assert store.get("timmy")["items"] == []

View File

@ -46,6 +46,7 @@ process.stdout.write(JSON.stringify(state));
"url": "https://forge.example/issue/42",
"has_unseen_change": False,
"kind": "issue",
"following": True,
}]
@ -133,6 +134,7 @@ const feature = createFollowing({{
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 all(item["following"] is True for item in result["session"]["items"])
assert result["session"]["more"] is False
assert result["state"] == {"opened": [42], "acknowledged": [42]}
@ -196,6 +198,46 @@ const feature = createFollowing({{
).stdout) == {"beforeFinish": [], "completed": ["following"]}
def test_following_retirement_removes_closed_item_and_keeps_review_moving():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{renders:[], counts:[], completed:[]}};
const items=[
{{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',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 () => {{}},
onReviewComplete:() => state.completed.push('following'),
}});
(async () => {{
await feature.load();
await feature.startReview();
const first=feature.session().items[0];
const next=feature.retire(first);
await feature.previewLoaded(next);
const final=feature.retire(next);
const handoff=feature.finishReview();
process.stdout.write(JSON.stringify({{
next:next.number, final, handoff, items:feature.items(), 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["next"] == 9
assert result["final"] is None
assert result["handoff"] is True
assert result["items"] == []
assert result["session"] is None
assert result["state"]["counts"][-1] == 0
assert result["state"]["completed"] == ["following"]
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
@ -250,11 +292,12 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
assert "query('#review-following').addEventListener('click'" in following
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
assert "afterUnwatch:item => followingQueue.retire(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-v133" in service_worker
assert "stackchain-dashboard-shell-v134" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -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-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -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-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker

View File

@ -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-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -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-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -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-v133" in worker
assert "stackchain-dashboard-shell-v134" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -414,7 +414,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-v133" in service_worker
assert "stackchain-dashboard-shell-v134" in service_worker
@pytest.mark.anyio

View File

@ -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-v133" in source
assert "stackchain-dashboard-shell-v134" 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

View File

@ -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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" 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-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -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-v133';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v134';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -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-v133" in source
assert "stackchain-dashboard-shell-v134" in source
assert "BASE + 'static/today-sync.js'" in source