feat: reply, mark read, and continue updates (Closes #503)
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 18:02:34 +00:00
1 parent 4f0e6544b3
commit 1052ce2c84
17 files changed
+335 -68

No files matched your search

+7 -9
View File
@@ -292,17 +292,15 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
@pytest.mark.anyio
async def test_current_today_update_offers_reply_and_next_without_marking_read():
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
html = await dashboard()
assert 'id="send-update-reply-next"' in html
assert '>Reply &amp; next</button>' in html
assert "setUpdateReplyNextVisibility();" in html
assert "const updateReplyNext = createCommentNext({" in html
assert "notificationReplier.saveDraft(item, '');" in html
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
assert "qs('#mark-update-read-next').click()" not in html
assert 'id="send-update-reply-read-next"' in html
assert '>Reply, mark read &amp; next</button>' in html
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
assert "markRead: markNotificationRead" in html
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" 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
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v83" in worker
assert "stackchain-dashboard-shell-v84" in worker
+1 -1
View File
@@ -347,5 +347,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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
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-v83" in worker
assert "stackchain-dashboard-shell-v84" in worker
+1 -1
View File
@@ -41,7 +41,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-v83" in worker
assert "stackchain-dashboard-shell-v84" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+11 -10
View File
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v83" in source
assert "stackchain-dashboard-shell-v84" 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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +146,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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v83" in source
assert "stackchain-dashboard-shell-v84" 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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -170,21 +170,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-v83" in source
assert "stackchain-dashboard-shell-v84" 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-v83" in source
assert "stackchain-dashboard-shell-v84" 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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -385,7 +385,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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -426,6 +426,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",
"/dashboard/static/update-reply-read-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
+1 -1
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():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v83';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v84';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
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-v83" in source
assert "stackchain-dashboard-shell-v84" in source
assert "BASE + 'static/today-sync.js'" in source
+176
View File
@@ -0,0 +1,176 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).parents[1]
CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js"
SYNC = ROOT / "frontend" / "background-issue-sync.js"
DRAFTS = ROOT / "frontend" / "drafts.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
@pytest.mark.anyio
async def test_every_unread_update_offers_one_reply_read_next_mobile_action():
html = await dashboard()
assert 'id="send-update-reply-read-next"' in html
assert '>Reply, mark read &amp; next</button>' in html
assert "workSession.checkpointed(selectedUpdate)" not in html
assert '<script src="static/update-reply-read-next.js"></script>' in html
assert ".update-reply-actions button { min-height:44px; width:100%; }" in html
def test_reply_read_next_orders_online_delivery_and_is_single_flight():
script = f"""
const createController = require({json.dumps(str(CONTROLLER))});
const calls=[]; let releaseReply;
const controller=createController({{
post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}},
markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}},
queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false,
accept:item=>calls.push(['accept',item.notification_id]),
next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}},
}});
const item={{notification_id:91}};
const first=controller.submit(item,'Done','op-91');
const second=controller.submit(item,'Done','op-91');
releaseReply({{id:7}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}})));
"""
output = run_node(script)
assert output == {
"same": True,
"calls": [
["reply", 91, "Done", "op-91"],
["read", 91],
["accept", 91],
["next", 91],
],
"results": [
{"accepted": True, "delivery": "posted", "next": {"next": True}},
{"accepted": True, "delivery": "posted", "next": {"next": True}},
],
}
def test_reply_read_next_waits_until_the_next_update_is_open():
script = f"""
const createController=require({json.dumps(str(CONTROLLER))});
let openNext;
const controller=createController({{
post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false,
next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})),
}});
let settled=false;
const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}});
setTimeout(()=>{{
const before=settled;
openNext();
result.then(value=>process.stdout.write(JSON.stringify({{before,value}})));
}},0);
"""
output = run_node(script)
assert output == {
"before": False,
"value": {
"accepted": True,
"delivery": "posted",
"next": {"notification_id": 2},
},
}
def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting():
script = f"""
const createController = require({json.dumps(str(CONTROLLER))});
const calls=[];
const offline=Object.assign(new Error('offline'),{{status:503}});
const controller=createController({{
post:async()=>{{calls.push('reply');return{{id:8}};}},
markRead:async()=>{{calls.push('read');throw offline;}},
canQueue:error=>error.status===503,
queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}},
accept:item=>calls.push(['accept',item.notification_id]),
next:item=>{{calls.push(['next',item.notification_id]);return true;}},
}});
controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=>
process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
assert output["calls"] == [
"reply",
"read",
["queue", {
"kind": "update-reply-read",
"notificationId": 22,
"body": "Ship it",
"operationId": "stable-op",
"replyConfirmed": True,
}],
["accept", 22],
["next", 22],
]
assert output["result"] == {"accepted": True, "delivery": "queued", "next": True}
def test_background_retry_resumes_at_read_after_reply_checkpoint():
item = {
"id": "op-31", "operationId": "op-31", "ownerLogin": "timmy",
"status": "queued", "kind": "update-reply-read", "notificationId": 31,
"body": "Acknowledged", "replyConfirmed": True,
}
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let queued={json.dumps(item)};const calls=[];
const store={{
claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null,
complete:async()=>calls.push('complete'),release:async()=>calls.push('release'),
fail:async()=>calls.push('fail'),countBlocked:async()=>0,
checkpoint:async()=>{{throw new Error('must not checkpoint again');}},
}};
const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}};
createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
assert output["calls"] == [
["api/v1/background-identity", None],
["api/v1/notifications/31/read", "PATCH"],
"complete",
]
assert output["result"]["confirmed"] == [{"ok": True}]
def test_partial_reply_read_delivery_is_recoverable_from_update_drafts():
record = {"version": 2, "items": [{
"id": "op-44", "operationId": "op-44", "ownerLogin": "timmy",
"status": "attention", "kind": "update-reply-read", "notificationId": 44,
"body": "Handled", "replyConfirmed": True, "error": "Read failed",
}]}
script = f"""
const createDraftInbox=require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]);
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null,
setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["outbox_kind"] == "update-reply-read"
assert output["title"] == "Update #44"
assert output["route"] == {"kind": "update", "notification_id": 44}
assert output["status"] == "attention"