diff --git a/README.md b/README.md
index 463f1c7..2739222 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,12 @@ another worker replays a confirmed result instead of posting duplicate content.
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
`updated_at` revisions make no detail request, transient failures retain the prior copy,
-and pull-request diffs remain online-only.
+and pull-request diffs remain online-only. Saved unread-update conversations remain
+triageable offline: **Queue read & next** writes an account-bound, notification-ID-
+deduplicated acknowledgement to the durable background delivery system, removes the
+update from the local queue immediately, and opens the next saved conversation. A cold
+offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
+authenticated notification-read endpoint and keeps transient failures queued.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 14c89e3..98e8841 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -232,6 +232,9 @@ function createBackgroundIssueSync({
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
}
+ if (item.kind === 'notification-read') {
+ return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
+ }
if (item.kind === 'update-reply') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
@@ -244,6 +247,12 @@ function createBackgroundIssueSync({
}
function deliveryRequest(item) {
+ if (item.kind === 'notification-read') {
+ return {
+ url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
+ options: { method: 'PATCH', headers: { Accept: 'application/json' } },
+ };
+ }
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 7ef3ee5..2824ce1 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -208,10 +208,16 @@
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
+ const notificationReadOutbox = createNotificationReadOutbox({
+ storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
+ backgroundSync: backgroundIssueSync,
+ getOwnerLogin: () => confirmedOwnerLogin,
+ });
if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => {
issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records);
+ notificationReadOutbox.reconcileBackground(records);
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
}
const shareParams = new URLSearchParams(location.search);
@@ -439,6 +445,8 @@
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
+ queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
+ loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
selectedUpdate = item;
qs('#update-sheet').classList.add('open');
@@ -521,7 +529,7 @@
notificationReader.open(item, savedDetail).then(opened => {
if (opened && selectedUpdate === item) {
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
- ' · replies queue for sync. Reconnect to mark read, take ownership, defer, or load older messages.';
+ ' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
}
});
} else {
@@ -679,7 +687,8 @@
}
function setOfflineUpdateControls(offline) {
- qs('#mark-update-read-next').disabled = offline;
+ qs('#mark-update-read-next').disabled = false;
+ qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#update-ownership-action').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline;
@@ -1714,6 +1723,15 @@
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
}
+ async function flushNotificationReadOutbox() {
+ if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
+ const result = await notificationReadOutbox.flush(activeFlushLogin);
+ if (result.confirmed?.length) {
+ qs('#my-work-action-status').textContent = result.confirmed.length +
+ ' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
+ }
+ }
+
function canQueueMessage(error) {
const status = Number(error?.status || 0);
return !status || status >= 500;
@@ -1965,6 +1983,7 @@
}
flushIssueOutbox();
flushAuthoredOutbox();
+ flushNotificationReadOutbox();
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
@@ -3083,7 +3102,8 @@
confirmedOwnerLogin = String(saved.user?.login || '').trim();
planningOwnerLogin = confirmedOwnerLogin;
updatePlanningAvailability();
- lastNotifications = saved.notifications || [];
+ saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
+ lastNotifications = saved.notifications;
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {};
lastContextSnapshot = saved;
diff --git a/frontend/index.html b/frontend/index.html
index 6e38b91..01b7429 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -533,6 +533,7 @@
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 52124dd..683683b 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -280,6 +280,8 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
+ queueRead = null,
+ loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
@@ -344,20 +346,21 @@ function createNotificationReader({
}
},
async markReadAndNext(items) {
- if (!selected || marking || offlineHydrated) return false;
+ if (!selected || marking || (offlineHydrated && !queueRead)) return false;
const current = selected;
+ const queueing = offlineHydrated;
marking = true;
- onStatus('Marking update read…');
+ onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
try {
- await markRead(current.notification_id);
+ if (queueing) await queueRead(current.notification_id);
+ else await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
- const next = remaining.find(item =>
- item && item.has_update && Number.isInteger(item.notification_id)
- );
- if (next) await open(next);
+ const next = remaining.find(item => item && item.has_update &&
+ Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
+ if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
@@ -365,7 +368,7 @@ function createNotificationReader({
}
return { items: updated, next: next || null };
} catch (_error) {
- onStatus('Could not mark update read. Retry.');
+ onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false;
} finally {
marking = false;
diff --git a/frontend/notification-read-outbox.js b/frontend/notification-read-outbox.js
new file mode 100644
index 0000000..cfc8ff5
--- /dev/null
+++ b/frontend/notification-read-outbox.js
@@ -0,0 +1,125 @@
+function createNotificationReadOutbox({
+ storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
+}) {
+ const storageKey = 'stackchain.notification-read-outbox.v1';
+
+ function read() {
+ try {
+ const record = JSON.parse(storage?.getItem(storageKey) || 'null');
+ if (record?.version !== 1 || !Array.isArray(record.items)) return [];
+ return record.items.filter(item => item?.kind === 'notification-read' &&
+ Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
+ } catch (_error) { return []; }
+ }
+
+ function write(items, mirror = true) {
+ storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
+ coordinator?.notify('notification-read');
+ if (mirror && backgroundSync?.reconcile) {
+ Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
+ .then(() => items.length ? backgroundSync.requestSync?.() : undefined)
+ .catch(() => { /* Foreground reconnect remains available. */ });
+ }
+ }
+
+ function itemId(ownerLogin, notificationId) {
+ return 'notification-read:' + ownerLogin + ':' + notificationId;
+ }
+
+ async function enqueueDurably(notificationId) {
+ notificationId = Number(notificationId);
+ const ownerLogin = String(getOwnerLogin() || '').trim();
+ if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
+ if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
+ const items = read();
+ const id = itemId(ownerLogin, notificationId);
+ let item = items.find(candidate => candidate.id === id);
+ if (!item) {
+ if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
+ item = {
+ id, kind: 'notification-read', notificationId, ownerLogin,
+ status: 'queued', queuedAt: Number(now()),
+ };
+ items.push(item);
+ write(items, false);
+ }
+ if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
+ return { item: { ...item }, background: false, durability: 'foreground-only' };
+ }
+ try {
+ await backgroundSync.reconcile(read(), 'notification-read');
+ await backgroundSync.requestSync();
+ return { item: { ...item }, background: true, durability: 'background' };
+ } catch (error) {
+ return { item: { ...item }, background: false, durability: 'foreground-only', error };
+ }
+ }
+
+ function suppress(items, login = getOwnerLogin()) {
+ const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
+ .map(item => item.notificationId));
+ return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
+ }
+
+ async function send(item, currentLogin) {
+ if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
+ if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
+ await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
+ method: 'PATCH', headers: { Accept: 'application/json' },
+ });
+ return { confirmed: true };
+ }
+
+ async function flush(currentLogin) {
+ currentLogin = String(currentLogin || '').trim();
+ const confirmed = [];
+ let items = read();
+ for (const item of items) {
+ if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
+ try {
+ const result = await send(item, currentLogin);
+ if (result?.blocked || result?.busy) continue;
+ if (result?.attention) {
+ items = items.map(candidate => candidate.id === item.id ? {
+ ...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
+ } : candidate);
+ write(items);
+ continue;
+ }
+ confirmed.push(item.notificationId);
+ items = items.filter(candidate => candidate.id !== item.id);
+ write(items);
+ } catch (error) {
+ const status = Number(error?.status || 0);
+ if (status >= 400 && status < 500) {
+ items = items.map(candidate => candidate.id === item.id ? {
+ ...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
+ } : candidate);
+ write(items);
+ continue;
+ }
+ break;
+ }
+ }
+ return { confirmed, remaining: read() };
+ }
+
+ function reconcileBackground(records) {
+ const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
+ .map(item => [item.id, item]));
+ const items = read().flatMap(item => {
+ const state = states.get(item.id);
+ if (state?.status === 'sent') return [];
+ if (state?.status === 'attention') return [{
+ ...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
+ }];
+ return [item];
+ });
+ write(items);
+ return items;
+ }
+
+ return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 183d208..a1e96a4 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v41';
+const CACHE = 'stackchain-dashboard-shell-v42';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -19,6 +19,7 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
+ BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 51f4990..fe8b9c3 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,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-v41" in worker
+ assert "stackchain-dashboard-shell-v42" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 5ce45c7..c6ec534 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -35,4 +35,4 @@ 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-v41" in worker
+ assert "stackchain-dashboard-shell-v42" in worker
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index ba48d20..5c48212 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -2406,6 +2406,51 @@ const reader = build.createNotificationReader({{
}
+def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
+ script = f"""
+const build = require({json.dumps(str(MY_WORK))});
+const items = [
+ {{kind:'update', notification_id:42, has_update:true, title:'First'}},
+ {{kind:'update', notification_id:43, has_update:true, title:'Second'}},
+];
+const saved = new Map([
+ [42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
+ [43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
+]);
+const events = [];
+const reader = build.createNotificationReader({{
+ load: async () => {{ throw new Error('network must not run'); }},
+ markRead: async () => {{ throw new Error('network must not run'); }},
+ queueRead: async id => events.push(['queued', id]),
+ loadSaved: item => saved.get(item.notification_id),
+ onOpen: item => events.push(['open', item.notification_id]),
+ onDetail: detail => events.push(['detail', detail.id]),
+ onItems: next => events.push(['items', next.map(item => item.notification_id)]),
+ onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
+}});
+(async () => {{
+ await reader.open(items[0], saved.get(42));
+ events.length = 0;
+ const result = await reader.markReadAndNext(items);
+ process.stdout.write(JSON.stringify({{result, events}}));
+}})();
+"""
+ output = json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+ assert output["result"]["next"]["notification_id"] == 43
+ assert output["events"] == [
+ ["status", "Queueing update read…"],
+ ["queued", 42],
+ ["items", [43]],
+ ["open", 43],
+ ["status", "Loading update…"],
+ ["detail", 43],
+ ["status", "Update ready."],
+ ]
+
+
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
diff --git a/tests/test_notification_read_outbox.py b/tests/test_notification_read_outbox.py
new file mode 100644
index 0000000..cee3e86
--- /dev/null
+++ b/tests/test_notification_read_outbox.py
@@ -0,0 +1,135 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+ROOT = Path(__file__).resolve().parents[1]
+OUTBOX = ROOT / "frontend" / "notification-read-outbox.js"
+BACKGROUND_SYNC = ROOT / "frontend" / "background-issue-sync.js"
+WORKER = ROOT / "frontend" / "service-worker.js"
+
+
+def run_node(script):
+ result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+ return json.loads(result.stdout)
+
+
+def test_offline_read_admission_is_account_bound_deduplicated_and_suppresses_saved_updates():
+ script = f"""
+const createOutbox = require({json.dumps(str(OUTBOX))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value),
+}};
+const reconciled = [];
+const backgroundSync = {{
+ reconcile: async (items, lane) => reconciled.push([lane, items]),
+ requestSync: async () => true,
+}};
+const outbox = createOutbox({{
+ storage, backgroundSync, getOwnerLogin:() => 'timmy', now:() => 1234,
+}});
+(async () => {{
+ const first = await outbox.enqueueDurably(42);
+ const duplicate = await outbox.enqueueDurably(42);
+ const visible = outbox.suppress([
+ {{notification_id:42, title:'Queued'}}, {{notification_id:43, title:'Visible'}},
+ ]);
+ process.stdout.write(JSON.stringify({{
+ first, duplicate, items:outbox.list(), visible, reconciled,
+ }}));
+}})();
+"""
+ output = run_node(script)
+
+ assert output["first"]["item"]["notificationId"] == 42
+ assert output["duplicate"]["item"]["id"] == output["first"]["item"]["id"]
+ assert len(output["items"]) == 1
+ assert output["items"][0]["ownerLogin"] == "timmy"
+ assert [item["notification_id"] for item in output["visible"]] == [43]
+ assert output["reconciled"][-1][0] == "notification-read"
+ assert len(output["reconciled"][-1][1]) == 1
+
+
+def test_foreground_flush_patches_each_unique_read_and_keeps_transient_failures_queued():
+ script = f"""
+const createOutbox = require({json.dumps(str(OUTBOX))});
+const values = new Map();
+const storage = {{getItem:key => values.get(key) || null,setItem:(key,value) => values.set(key,value)}};
+const calls = [];
+const outbox = createOutbox({{
+ storage, getOwnerLogin:() => 'timmy',
+ fetchJson: async (url, options) => {{
+ calls.push([url, options.method]);
+ if (url.includes('/43/')) {{ const error = new Error('offline'); error.status = 503; throw error; }}
+ return {{ok:true}};
+ }},
+}});
+(async () => {{
+ await outbox.enqueueDurably(42);
+ await outbox.enqueueDurably(43);
+ const result = await outbox.flush('timmy');
+ process.stdout.write(JSON.stringify({{calls, result, items:outbox.list()}}));
+}})();
+"""
+ output = run_node(script)
+
+ assert output["calls"] == [
+ ["api/v1/notifications/42/read", "PATCH"],
+ ["api/v1/notifications/43/read", "PATCH"],
+ ]
+ assert output["result"]["confirmed"] == [42]
+ assert [item["notificationId"] for item in output["items"]] == [43]
+ assert output["items"][0]["status"] == "queued"
+
+
+def test_background_delivery_uses_patch_and_reports_read_receipt():
+ script = f"""
+const createSync = require({json.dumps(str(BACKGROUND_SYNC))});
+const requests = [];
+const store = {{
+ upsert:async () => {{}}, claim:async () => ({{
+ id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
+ ownerLogin:'timmy', status:'sending',
+ }}), complete:async () => {{}}, release:async () => {{}}, fail:async () => {{}},
+}};
+const sync = createSync({{
+ store,
+ fetchJson:async (url, options) => {{ requests.push([url, options.method, options.body || null]); return {{ok:true}}; }},
+}});
+(async () => {{
+ const result = await sync.send({{
+ id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
+ ownerLogin:'timmy', status:'queued',
+ }}, 'timmy');
+ process.stdout.write(JSON.stringify({{requests, result}}));
+}})();
+"""
+ output = run_node(script)
+
+ assert output["requests"] == [["api/v1/notifications/42/read", "PATCH", None]]
+ assert output["result"]["receipt"] == {
+ "id": "notification-read:timmy:42",
+ "status": "confirmed",
+ "kind": "notification-read",
+ "route": "#/my-work/updates",
+ }
+
+
+@pytest.mark.anyio
+async def test_dashboard_wires_offline_queue_read_next_through_precached_background_outbox():
+ html = await dashboard()
+ worker = WORKER.read_text()
+
+ assert '' in html
+ assert "const notificationReadOutbox = createNotificationReadOutbox({" in html
+ assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
+ assert "notificationReadOutbox.suppress(saved.notifications || [])" in html
+ assert "notificationReadOutbox.flush(activeFlushLogin)" in html
+ assert "Queue read & next" in html
+ assert "BASE + 'static/notification-read-outbox.js'" in worker
diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py
index 402f551..13b29a8 100644
--- a/tests/test_offline_work.py
+++ b/tests/test_offline_work.py
@@ -205,7 +205,7 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac
@pytest.mark.anyio
-async def test_saved_unread_update_opens_offline_with_reply_only_controls():
+async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls():
html = await dashboard()
assert "notificationReader.open(item, savedDetail)" in html
@@ -213,8 +213,9 @@ async def test_saved_unread_update_opens_offline_with_reply_only_controls():
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
assert "Offline update · saved " in html
assert "setOfflineUpdateControls(true)" in html
- assert "qs('#mark-update-read-next').disabled = offline;" in html
+ assert "qs('#mark-update-read-next').disabled = false;" in html
+ assert "offline ? 'Queue read & next' : 'Mark read & next'" in html
assert "qs('#update-ownership-action').disabled = offline;" in html
assert "qs('#load-older-update-comments').disabled = offline;" in html
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
- assert "Reconnect to mark read, take ownership, defer, or load older messages." in html
+ assert "replies and read acknowledgements queue for sync" in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 2d71bb4..16576a3 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v41" in source
+ assert "stackchain-dashboard-shell-v42" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v41" in source
+ assert "stackchain-dashboard-shell-v42" 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-v41" in source
+ assert "stackchain-dashboard-shell-v42" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -303,6 +303,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
+ "/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",