Merge pull request 'Continue unread-update conversations offline' (#348) from timmy/347-offline-update-conversations into main
All checks were successful
CI / lint (push) Successful in 36s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
timmy 2026-08-08 22:43:48 +00:00
commit e11e55149c
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
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
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.
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,
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
work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically.

View File

@ -429,6 +429,7 @@
qs('#send-update-reply').disabled = false;
qs('#update-ownership-action').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: detail => {
@ -437,7 +438,14 @@
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
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;
},
onConversation: renderUpdateConversation,
@ -470,7 +478,8 @@
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
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.';
return;
}
@ -480,6 +489,14 @@
} else if (item.kind === 'pull' && !item.is_review) {
pullTrigger = trigger;
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 {
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) {
liveMode = true;
hasContextSnapshot = true;
@ -959,7 +983,7 @@
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
workRoute.open({ ...item, kind:'update' });
openRoutedWork(item, button);
});
});
document.querySelectorAll('[data-notification-id]').forEach(button => {
@ -3018,6 +3042,10 @@
offlineWorkMode = value;
['#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; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
}
function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load();

View File

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

View File

@ -19,6 +19,7 @@
];
const DETAIL_FIELDS = [
'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'];
@ -48,6 +49,10 @@
}
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 repository = String(item?.repository || '');
const number = Number(item?.number || 0);

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
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 SHELL = [
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 pre { max-width:100%; overflow-x:auto;" 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]))
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]]
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():
script = f"""
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"]
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
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
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('#pull-review').inert = false;" 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 "comments can enter the account-bound durable outbox" 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():
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.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-v39" in source
assert "stackchain-dashboard-shell-v40" 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-v39" in source
assert "stackchain-dashboard-shell-v40" in source
assert "BASE + 'static/update-ownership.js'" in source