feat: retire closed issues during Following review (Closes #1305)
Some checks failed
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 12:12:55 +00:00
parent fdf38a72ac
commit c4bb7a9be1
20 changed files with 278 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, 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, 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 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 **Stop watching** removes an open item. When watched work closes, the sequential review exposes
unchanged. Following counts never influence the recommended Work queue. Set **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`. `STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. 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}); if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
}, },
onOpened:item => followingQueue.previewLoaded(item), onOpened:item => followingQueue.previewLoaded(item),
afterUnwatch:item => followingQueue.retire(item),
navigationRoot:document, navigationRoot:document,
onState: renderSearchPreview, onState: renderSearchPreview,
}); });

View File

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

View File

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

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; 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( 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]: ) -> tuple[str, dict]:
repository = f"{owner}/{repo}" repository = f"{owner}/{repo}"
preview = await gitea_proxy.work_preview(repository, kind, number) 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("repository") != repository
or preview.get("kind") != "issue" or preview.get("kind") != "issue"
or preview.get("number") != number 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") raise HTTPException(status_code=404, detail="Watchable search result not found")
return repository, preview return repository, preview
@ -3790,7 +3790,9 @@ async def mutate_global_search_preview_subscription(
) -> JSONResponse: ) -> JSONResponse:
watching = request.method == "PUT" watching = request.method == "PUT"
try: 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() login = await _confirmed_login()
store = _following_store() store = _following_store()
following_item = { following_item = {

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 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""" script = f"""
require({json.dumps(str(SEARCH_PREVIEW))}); require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{ (async () => {{
@ -925,7 +925,10 @@ for (const detail of [
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate); globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
excluded.push(candidate.hidden); 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);}}); }})().catch(error=>{{console.error(error);process.exit(1);}});
""" """
result = subprocess.run(["node", "-e", script], capture_output=True, text=True) 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, "disabled": False,
} }
assert payload["excluded"] == [True, True] 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(): 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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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.status_code == 200
assert response.json()["items"][0]["has_unseen_change"] is False 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", "url": "https://forge.example/issue/42",
"has_unseen_change": False, "has_unseen_change": False,
"kind": "issue", "kind": "issue",
"following": True,
}] }]
@ -133,6 +134,7 @@ const feature = createFollowing({{
assert result["started"] is True assert result["started"] is True
assert [item["number"] for item in result["session"]["items"]] == [42, 9] 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["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["session"]["more"] is False
assert result["state"] == {"opened": [42], "acknowledged": [42]} assert result["state"] == {"opened": [42], "acknowledged": [42]}
@ -196,6 +198,46 @@ const feature = createFollowing({{
).stdout) == {"beforeFinish": [], "completed": ["following"]} ).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(): def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
script = f""" script = f"""
const createFollowing = require({json.dumps(str(MODULE))}); 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 "query('#review-following').addEventListener('click'" in following
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
assert "followingQueue.returnToFollowing()" in dashboard assert "followingQueue.returnToFollowing()" in dashboard
assert "'Back to Following'" in dashboard assert "'Back to Following'" in dashboard
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && 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(): 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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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])) 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 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(): 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 "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" 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(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() 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 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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 @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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.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(): def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() 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(): def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() 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/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.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(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() 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-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() 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/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 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(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source