Reply
diff --git a/frontend/mobile-search-preview-nav.js b/frontend/mobile-search-preview-nav.js
index 73d0cc1..bb6c6c5 100644
--- a/frontend/mobile-search-preview-nav.js
+++ b/frontend/mobile-search-preview-nav.js
@@ -98,12 +98,14 @@ function attachMobileSearchPreviewNavigation({document, window}) {
buttons:{
overview:bySection('overview'),
conversation:bySection('conversation'),
+ changes:bySection('changes'),
reply:bySection('reply'),
actions:bySection('actions'),
},
targets:{
overview:document.getElementById('search-preview-overview'),
conversation:document.getElementById('search-preview-conversation'),
+ changes:document.getElementById('search-preview-review'),
reply,
actions:document.getElementById('search-preview-actions'),
},
diff --git a/frontend/search-preview.js b/frontend/search-preview.js
index b6df019..74f43e1 100644
--- a/frontend/search-preview.js
+++ b/frontend/search-preview.js
@@ -30,6 +30,10 @@
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
+ root.followingPullReviewPath = item => {
+ const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
+ return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
+ };
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
@@ -47,6 +51,7 @@
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
}),
+ review:item => fetchJson(root.followingPullReviewPath(item)),
};
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
headers:{Accept:'application/json'},
@@ -165,9 +170,58 @@
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
+ root.renderSearchPreviewReview = (review, document, escapeHtml) => {
+ const section = document.querySelector('#search-preview-review');
+ const status = document.querySelector('#search-preview-review-status');
+ const files = document.querySelector('#search-preview-files');
+ const retry = document.querySelector('#retry-search-preview-review');
+ section.hidden = !review;
+ retry.hidden = review?.status !== 'error';
+ files.innerHTML = '';
+ if (!review) {
+ status.textContent = '';
+ return;
+ }
+ if (review.status === 'loading') {
+ status.textContent = 'Loading CI and changed files…';
+ return;
+ }
+ if (review.status === 'error') {
+ status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
+ return;
+ }
+ const data = review.data || {};
+ const changed = Array.isArray(data.files) ? data.files : [];
+ const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
+ data.ci_state
+ ] || 'CI status unavailable';
+ status.textContent = ci + ' · ' + changed.length + ' changed ' +
+ (changed.length === 1 ? 'file.' : 'files.');
+ files.innerHTML = changed.map(file => {
+ const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
+ const line = String(raw);
+ const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
+ line.startsWith('-') ? 'removed' : 'context';
+ return '' + escapeHtml(line) + '';
+ }).join('');
+ const diff = file.diff_available
+ ? '' + lines +
+ (file.diff_truncated ? 'Preview truncated · open in Gitea for the full diff.' : '') + '
'
+ : '' +
+ (file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '
';
+ return '' + escapeHtml(file.filename || 'Unknown file') +
+ '' + escapeHtml(file.status || 'changed') + ' · +' +
+ Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '' + diff + '';
+ }).join('');
+ };
+ root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
+ root.renderSearchPreviewReply(state, detail, preview, document);
+ root.renderSearchPreviewReview(state.review, document, escapeHtml);
+ document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
+ };
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
- return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
+ return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, 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();
@@ -182,6 +236,7 @@
let replyRequest = null;
let watchRequest = null;
let conversation = null;
+ let review = null;
let openedRevision = null;
function sameItem(left, right) {
@@ -228,6 +283,10 @@
}
function publish(state) {
+ if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
+ state = {...state, conversation};
+ }
+ if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
@@ -293,6 +352,37 @@
});
}
+ function loadReview(detail, requestGeneration) {
+ if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
+ review = null;
+ return Promise.resolve(null);
+ }
+ review = {status:'loading', data:null};
+ publish({status:'ready', item:current, detail:current, conversation, review});
+ return fetchReview(detail).then(data => {
+ if (requestGeneration === generation) {
+ review = {status:'ready', data};
+ publish({status:'ready', item:current, detail:current, conversation, review});
+ }
+ return data;
+ }).catch(error => {
+ if (requestGeneration === generation) {
+ review = {status:'error', data:null, error};
+ publish({status:'ready', item:current, detail:current, conversation, review});
+ }
+ return null;
+ });
+ }
+
+ async function acknowledgeWhenContextReady(requestGeneration) {
+ const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
+ const reviewRequired = current?.following === true && current?.kind === 'pull' &&
+ typeof fetchReview === 'function';
+ if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
+ await notifyOpened(current, requestGeneration);
+ }
+ }
+
const api = {
hasReplyAttachments() {
return Boolean(hasAttachments?.());
@@ -303,6 +393,7 @@
const requestGeneration = generation;
current = { ...item };
conversation = null;
+ review = null;
openedRevision = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(async detail => {
@@ -311,8 +402,9 @@
if (typeof fetchConversation === 'function') {
const context = loadConversation(current, requestGeneration);
if (current.following === true) {
- await context;
- if (conversation?.status === 'ready') await notifyOpened(current, requestGeneration);
+ const reviewContext = loadReview(current, requestGeneration);
+ await Promise.all([context, reviewContext]);
+ await acknowledgeWhenContextReady(requestGeneration);
} else {
await notifyOpened(current, requestGeneration);
}
@@ -371,9 +463,14 @@
if (!current) return null;
const requestGeneration = generation;
const result = await loadConversation(current, requestGeneration);
- if (current?.following === true && conversation?.status === 'ready') {
- await notifyOpened(current, requestGeneration);
- }
+ if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
+ return result;
+ },
+ async retryReview() {
+ if (!current) return null;
+ const requestGeneration = generation;
+ const result = await loadReview(current, requestGeneration);
+ await acknowledgeWhenContextReady(requestGeneration);
return result;
},
loadOlderConversation() {
@@ -482,6 +579,7 @@
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
+ navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index cc49331..bcf0a16 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-v137';
+const CACHE = 'stackchain-dashboard-shell-v138';
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/src/main.py b/src/main.py
index 6182782..889babe 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2753,6 +2753,45 @@ async def keep_following_revision(
)
+@app.get("/api/v1/following/{owner}/{repo}/pulls/{number}/review-data")
+async def following_pull_review_data(
+ owner: str, repo: str, number: int = PathParam(gt=0)
+):
+ """Return bounded review context only for a pull in this account's Following list."""
+ repository = f"{owner}/{repo}"
+ login = await _confirmed_login()
+
+ async def load_review():
+ snapshot = await asyncio.to_thread(_following_store().get, login)
+ watched = any(
+ item.get("kind") == "pull"
+ and item.get("repository", "").lower() == repository.lower()
+ and item.get("number") == number
+ for item in snapshot["items"]
+ )
+ if not watched:
+ raise HTTPException(status_code=404, detail="Watched pull request not found")
+ return await gitea_proxy.pull_completion_review(repository, number)
+
+ try:
+ result = await asyncio.wait_for(load_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS)
+ except HTTPException:
+ raise
+ except TimeoutError:
+ return JSONResponse(
+ {"error": "Loading watched pull request changes timed out. Please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ except Exception:
+ return JSONResponse(
+ {"error": "Watched pull request changes are temporarily unavailable. Please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result, headers={"Cache-Control": "no-store"})
+
+
@app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()
diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py
index 591e976..d2b5a8b 100644
--- a/tests/e2e/test_mobile_following_release.py
+++ b/tests/e2e/test_mobile_following_release.py
@@ -58,6 +58,29 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
assert page.locator(".following-card").bounding_box()["height"] >= 44
assert page.locator("#close-following").bounding_box()["height"] >= 44
+ page.evaluate("""() => {
+ document.querySelector('#following-sheet').close();
+ document.querySelector('#search-preview').classList.add('open');
+ renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
+ filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
+ additions:1,deletions:1,diff_available:true,
+ diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
+ '+safe long line that scrolls inside the diff only']
+ }]}},document,value=>String(value));
+ document.querySelector('[data-search-preview-section="changes"]').hidden=false;
+ }""")
+ changes = page.locator("#search-preview-review")
+ expect(changes).to_be_visible()
+ expect(changes).to_contain_text("CI passed · 1 changed file.")
+ expect(changes).to_contain_text("review.py")
+ assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
+ assert page.locator("#search-preview-review .pull-diff").evaluate(
+ "node => node.scrollWidth >= node.clientWidth"
+ ) is True
+ assert page.evaluate(
+ "document.documentElement.scrollWidth > document.documentElement.clientWidth"
+ ) is False
+
retire = page.locator("#watch-search-result")
page.evaluate("""() => {
document.querySelector('#following-sheet').close();
diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py
index b6edbc7..8898ed9 100644
--- a/tests/test_command_palette.py
+++ b/tests/test_command_palette.py
@@ -1334,7 +1334,7 @@ def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
assert "detail.reopenable ? 'Reopen & resume'" in html
- assert "mutate: (detail, action) => fetchReviewJson(" in html
+ assert "mutate:(detail,action)=>fetchReviewJson(" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
)[0]
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 83e23ef..e70ed7e 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-v137" in worker
+ assert "stackchain-dashboard-shell-v138" in worker
diff --git a/tests/test_following_api.py b/tests/test_following_api.py
index 4119565..f114cc5 100644
--- a/tests/test_following_api.py
+++ b/tests/test_following_api.py
@@ -103,6 +103,63 @@ async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, t
]
+@pytest.mark.anyio
+async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path):
+ store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32)
+ store.set_watching("timmy", {
+ "repository": "stackchain/api", "kind": "pull", "number": 84,
+ "title": "Review watched changes", "state": "open",
+ "updated_at": "2026-08-24T05:00:00Z",
+ "url": "https://forge.example/stackchain/api/pulls/84",
+ }, True)
+ review = {
+ "head_sha": "abc123", "ci_state": "success",
+ "files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}],
+ }
+
+ async def user():
+ return {"login": "timmy"}
+
+ async def load_review(repository, number):
+ assert (repository, number) == ("stackchain/api", 84)
+ return review
+
+ monkeypatch.setattr(main, "_following_store", lambda: store)
+ monkeypatch.setattr(main, "current_user", user)
+ monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get(
+ "/api/v1/following/stackchain/api/pulls/84/review-data"
+ )
+
+ assert response.status_code == 200
+ assert response.headers["cache-control"] == "no-store"
+ assert response.json() == review
+
+
+@pytest.mark.anyio
+async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path):
+ store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32)
+
+ async def user():
+ return {"login": "timmy"}
+
+ async def unexpected_review(*_args):
+ raise AssertionError("unwatched pull must be rejected before upstream access")
+
+ monkeypatch.setattr(main, "_following_store", lambda: store)
+ monkeypatch.setattr(main, "current_user", user)
+ monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get(
+ "/api/v1/following/stackchain/api/pulls/84/review-data"
+ )
+
+ assert response.status_code == 404
+
+
@pytest.mark.anyio
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py
index 73de126..63e6f0e 100644
--- a/tests/test_following_frontend.py
+++ b/tests/test_following_frontend.py
@@ -176,6 +176,76 @@ const preview=createSearchPreview({{
}
+def test_following_pull_revision_waits_for_changes_and_ci_context():
+ preview_module = ROOT / "frontend" / "search-preview.js"
+ script = f"""
+const createSearchPreview=require({json.dumps(str(preview_module))});
+let unavailable=true;
+const opened=[];
+const states=[];
+const preview=createSearchPreview({{
+ fetchJson:async item=>({{...item,title:'Changed pull request'}}),
+ fetchConversation:async()=>({{comments:[]}}),
+ fetchReview:async()=>{{if(unavailable)throw new Error('diff offline');return {{ci_state:'success',files:[{{filename:'src/api.py',diff_lines:['+safe'],diff_available:true}}]}};}},
+ onOpened:async item=>opened.push(item.updated_at),
+ onState:state=>states.push({{status:state.review?.status,ci:state.review?.data?.ci_state}}),
+}});
+(async()=>{{
+ await preview.open({{
+ repository:'stackchain/api',kind:'pull',number:84,following:true,
+ reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
+ }});
+ const afterFailure=[...opened];
+ unavailable=false;
+ await preview.retryReview();
+ process.stdout.write(JSON.stringify({{afterFailure,opened,last:states.at(-1)}}));
+}})().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 == {
+ "afterFailure": [],
+ "opened": ["2026-08-23T04:00:00Z"],
+ "last": {"status": "ready", "ci": "success"},
+ }
+
+
+def test_watched_pull_changes_render_bounded_diff_and_ci_state():
+ preview_module = ROOT / "frontend" / "search-preview.js"
+ script = f"""
+require({json.dumps(str(preview_module))});
+const nodes={{
+ '#search-preview-review':{{hidden:true}},
+ '#search-preview-review-status':{{textContent:''}},
+ '#search-preview-files':{{innerHTML:''}},
+ '#retry-search-preview-review':{{hidden:true}},
+}};
+const document={{querySelector:selector=>nodes[selector]}};
+global.renderSearchPreviewReview({{status:'ready',data:{{ci_state:'success',files:[{{
+ filename:'src/api.py',status:'modified',additions:1,deletions:0,
+ diff_available:true,diff_lines:['@@ -1 +1 @@','-unsafe','+safe']
+}}]}}}},document,value=>String(value).replaceAll('<','<'));
+process.stdout.write(JSON.stringify({{
+ hidden:nodes['#search-preview-review'].hidden,
+ status:nodes['#search-preview-review-status'].textContent,
+ html:nodes['#search-preview-files'].innerHTML,
+ retry:nodes['#retry-search-preview-review'].hidden,
+}}));
+"""
+ result = json.loads(subprocess.run(
+ ["node", "-e", script], text=True, capture_output=True, check=True
+ ).stdout)
+
+ assert result["hidden"] is False
+ assert result["status"] == "CI passed · 1 changed file."
+ assert "src/api.py" in result["html"]
+ assert "pull-diff-line added" in result["html"]
+ assert result["retry"] is True
+
+
def test_following_typed_identity_prevents_issue_pull_collisions():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
@@ -523,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
- assert "stackchain-dashboard-shell-v137" in service_worker
+ assert "stackchain-dashboard-shell-v138" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 83f31d2..77f827d 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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 a4219da..02397e3 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-v137" in worker
+ assert "stackchain-dashboard-shell-v138" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index f350150..6fbff3f 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-v137" in worker
+ assert "stackchain-dashboard-shell-v138" 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 b0b5b65..a548756 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-v137" in worker
+ assert "stackchain-dashboard-shell-v138" 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 ae91f1d..e3377f9 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-v137" in worker
+ assert "stackchain-dashboard-shell-v138" 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 1d152f3..c7a6570 100644
--- a/tests/test_mobile_start_day.py
+++ b/tests/test_mobile_start_day.py
@@ -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-v137" in service_worker
+ assert "stackchain-dashboard-shell-v138" in service_worker
@pytest.mark.anyio
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index d70f448..a322539 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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 3fca3e5..894ac19 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -197,20 +197,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -219,7 +219,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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
@@ -228,7 +228,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -236,14 +236,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -251,7 +251,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -259,7 +259,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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
@@ -269,14 +269,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -285,21 +285,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1354,7 +1354,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-v137" in source
+ assert "stackchain-dashboard-shell-v138" 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 a1e7bf8..4f381ad 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-v137';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v138';" 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 23e2f7a..9f9b024 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-v137" in source
+ assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-sync.js'" in source