Continue unread-update conversations offline #348

Merged
timmy merged 1 commits from timmy/347-offline-update-conversations into main 2026-08-08 22:43:48 +00:00
11 changed files with 161 additions and 14 deletions

View File

@ -210,11 +210,15 @@ Users can explicitly enable **Keep My Work available offline**. Each healthy liv
refresh then stores a seven-day, versioned snapshot containing only the signed-in refresh then stores a seven-day, versioned snapshot containing only the signed-in
user identity and queue-card metadata for issues, pull requests, unread updates, user identity and queue-card metadata for issues, pull requests, unread updates,
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
newest 20 comments. This account-bound cache is limited to ten records; diffs, newest 20 comments. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages.
This account-bound cache is limited to ten records across all detail kinds; diffs,
credentials, repository catalogs, events, and complete API responses are excluded. credentials, repository catalogs, events, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox; planning, phone sheet, where comments can enter the account-bound durable outbox; planning,
assignment, review, merge, and close controls remain disabled until reconnection. assignment, review, merge, and close controls remain disabled until reconnection.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically. automatically.

View File

@ -429,6 +429,7 @@
qs('#send-update-reply').disabled = false; qs('#send-update-reply').disabled = false;
qs('#update-ownership-action').hidden = true; qs('#update-ownership-action').hidden = true;
qs('#retry-update-load').hidden = true; qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus(); qs('#keep-update-unread').focus();
}, },
onDetail: detail => { onDetail: detail => {
@ -437,7 +438,14 @@
qs('#update-subject-state').textContent = detail.state || ''; qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.'); qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
updateOwnership.open(detail, selectedUpdate); if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true; qs('#retry-update-load').hidden = true;
}, },
onConversation: renderUpdateConversation, onConversation: renderUpdateConversation,
@ -470,7 +478,8 @@
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin || const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim(); String(offlineWorkStore.load()?.user?.login || '').trim();
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item); const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
if (!todayWork.contains(item) || !savedDetail) { const savedUpdate = item.kind === 'update' && item.has_update;
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.'; qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
return; return;
} }
@ -480,6 +489,14 @@
} else if (item.kind === 'pull' && !item.is_review) { } else if (item.kind === 'pull' && !item.is_review) {
pullTrigger = trigger; pullTrigger = trigger;
openPullSheet(item, trigger, savedDetail); openPullSheet(item, trigger, savedDetail);
} else if (savedUpdate) {
updateTrigger = trigger;
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.';
}
});
} else { } else {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.'; qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
} }
@ -634,6 +651,13 @@
} }
} }
function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = offline;
qs('#update-ownership-action').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline;
}
function renderContextSnapshot(data) { function renderContextSnapshot(data) {
liveMode = true; liveMode = true;
hasContextSnapshot = true; hasContextSnapshot = true;
@ -959,7 +983,7 @@
const item = lastMyWork[Number(button.dataset.updateIndex)]; const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return; if (!item) return;
updateTrigger = button; updateTrigger = button;
workRoute.open({ ...item, kind:'update' }); openRoutedWork(item, button);
}); });
}); });
document.querySelectorAll('[data-notification-id]').forEach(button => { document.querySelectorAll('[data-notification-id]').forEach(button => {
@ -3018,6 +3042,10 @@
offlineWorkMode = value; offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read'] ['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; }); .forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
} }
function hydrateOfflineWork(mode = 'offline') { function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load(); const saved = offlineWorkStore.load();

View File

@ -288,14 +288,16 @@ function createNotificationReader({
let loadVersion = 0; let loadVersion = 0;
let marking = false; let marking = false;
let conversationPager = null; let conversationPager = null;
let offlineHydrated = false;
async function open(item) { async function open(item, savedDetail = null) {
selected = item; selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion; const version = ++loadVersion;
onOpen(item); onOpen(item);
onStatus('Loading update…'); onStatus('Loading update…');
try { try {
const detail = await load(item.notification_id); const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false; if (selected !== item || version !== loadVersion) return false;
onDetail(detail); onDetail(detail);
if (createPager && loadConversation && detail.conversation) { if (createPager && loadConversation && detail.conversation) {
@ -324,7 +326,7 @@ function createNotificationReader({
return true; return true;
}, },
async loadOlder() { async loadOlder() {
if (!conversationPager || !selected) return false; if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager; const pager = conversationPager;
const version = loadVersion; const version = loadVersion;
onStatus('Loading older messages…'); onStatus('Loading older messages…');
@ -342,7 +344,7 @@ function createNotificationReader({
} }
}, },
async markReadAndNext(items) { async markReadAndNext(items) {
if (!selected || marking) return false; if (!selected || marking || offlineHydrated) return false;
const current = selected; const current = selected;
marking = true; marking = true;
onStatus('Marking update read…'); onStatus('Marking update read…');

View File

@ -19,6 +19,7 @@
]; ];
const DETAIL_FIELDS = [ const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone', 'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
'id', 'repository', 'subject_type', 'subject_body',
]; ];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url']; const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
@ -48,6 +49,10 @@
} }
function detailKey(item) { function detailKey(item) {
if (item?.kind === 'update') {
const notificationId = Number(item?.notification_id || 0);
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : ''; const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
const repository = String(item?.repository || ''); const repository = String(item?.repository || '');
const number = Number(item?.number || 0); const number = Number(item?.number || 0);

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v39'; const CACHE = 'stackchain-dashboard-shell-v40';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,

View File

@ -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 { 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-v39" in worker assert "stackchain-dashboard-shell-v40" in worker

View File

@ -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])) 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-v39" in worker assert "stackchain-dashboard-shell-v40" in worker

View File

@ -2359,6 +2359,53 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]] assert output["states"] == [[41], [90]]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const saved = {{
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
}};
let detailLoads = 0;
let conversationLoads = 0;
let markReads = 0;
const details = [];
const conversations = [];
const reader = build.createNotificationReader({{
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
markRead: async () => {{ markReads += 1; }}, createPager,
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open(item, saved);
const older = await reader.loadOlder();
const marked = await reader.markReadAndNext([item]);
process.stdout.write(JSON.stringify({{
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"opened": True,
"older": False,
"marked": False,
"detailLoads": 0,
"conversationLoads": 0,
"markReads": 0,
"details": ["Saved update"],
"conversations": [[41]],
}
def test_notification_reader_appends_a_confirmed_reply_exactly_once(): def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f""" script = f"""
const build = require({json.dumps(str(MY_WORK))}); const build = require({json.dumps(str(MY_WORK))});

View File

@ -117,6 +117,43 @@ process.stdout.write(JSON.stringify({
assert "private diff" not in result["raw"] assert "private diff" not in result["raw"]
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const update = {kind:'update', notification_id:42};
store.saveDetail('timmy', update, {
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
conversation:{
comments:Array.from({length:25}, (_, index) => ({
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
})),
page:1, older_page:2, total:40,
},
});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
loaded:store.loadDetail('timmy', update),
wrongUser:store.loadDetail('alexander', update),
raw,
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "Deployment blocked"
assert result["loaded"]["subject_body"] == "Safe subject context"
assert result["loaded"]["subject_type"] == "Issue"
assert len(result["loaded"]["conversation"]["comments"]) == 20
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration(): async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard() html = await dashboard()
@ -165,3 +202,19 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac
assert "qs('#issue-planning').inert = false;" in html assert "qs('#issue-planning').inert = false;" in html
assert "qs('#pull-review').inert = false;" in html assert "qs('#pull-review').inert = false;" in html
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html
@pytest.mark.anyio
async def test_saved_unread_update_opens_offline_with_reply_only_controls():
html = await dashboard()
assert "notificationReader.open(item, savedDetail)" in html
assert "openRoutedWork(item, button);" in html
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('#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

View File

@ -41,3 +41,11 @@ def test_readme_documents_bounded_offline_today_details_and_safe_actions():
assert "newest 20 comments" in text assert "newest 20 comments" in text
assert "comments can enter the account-bound durable outbox" in text assert "comments can enter the account-bound durable outbox" in text
assert "planning, assignment, review, merge, and close controls remain disabled" in text assert "planning, assignment, review, merge, and close controls remain disabled" in text
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
text = " ".join(README.read_text().split())
assert "previously opened unread update" in text
assert "replies enter the account-bound durable outbox" in text
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text

View File

@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v39" in source assert "stackchain-dashboard-shell-v40" 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
@ -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(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v39" in source assert "stackchain-dashboard-shell-v40" 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-v39" in source assert "stackchain-dashboard-shell-v40" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source