Reply to updates and continue Today in one action #452

Merged
timmy merged 1 commits from timmy/451-update-reply-next into main 2026-08-10 04:17:32 +00:00
14 changed files with 158 additions and 26 deletions

View File

@ -48,8 +48,10 @@ stores an account-bound checkpoint on the current device. After a reload or inst
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed); restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
**Comment & next** on that current issue or pull request posts the handoff online or admits it **Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. Delivery or local-admission failure preserves both the draft one without closing or merging it. **Reply & next** provides the same one-action continuation
and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan for the current unread-update conversation. It deliberately leaves the notification unread;
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued and reconnecting or returning to the dashboard refreshes server truth after replaying queued

View File

@ -1,4 +1,4 @@
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) { function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null; let inFlight = null;
function submit(item, body, operationId = '') { function submit(item, body, operationId = '') {
@ -7,14 +7,17 @@ function createCommentNext({ post, queue, canQueue, accept = () => undefined, co
try { try {
let comment; let comment;
try { try {
comment = await post(item, body); comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
} catch (error) { } catch (error) {
if (!canQueue(error)) throw error; if (!canQueue(error)) throw error;
const admission = await queue({ const identity = queueKind === 'update-reply' ? {
kind: 'update-reply', notificationId: item.notification_id,
} : {
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment', kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository, repository: item.repository, number: item.number,
number: item.number, };
body, const admission = await queue({
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId, operationId: typeof operationId === 'function' ? operationId() : operationId,
}); });
if (!admission || (!admission.item && admission.durable !== true)) { if (!admission || (!admission.item && admission.durable !== true)) {

View File

@ -220,6 +220,8 @@ textarea { resize: vertical; min-height: 120px; }
.update-reply { display:grid; gap:8px; margin-top:16px; } .update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; } .update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; } .update-reply button { min-height:44px; width:100%; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions button { min-height:44px; width:100%; }
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } .update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }

View File

@ -554,6 +554,8 @@
qs('#update-reply').value = notificationReplier.loadDraft(item); qs('#update-reply').value = notificationReplier.loadDraft(item);
qs('#update-reply-status').textContent = ''; qs('#update-reply-status').textContent = '';
qs('#send-update-reply').disabled = false; qs('#send-update-reply').disabled = false;
qs('#send-update-reply-next').disabled = false;
setUpdateReplyNextVisibility();
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); setOfflineUpdateControls(false);
@ -929,6 +931,9 @@
const item = kind === 'issue' ? selectedIssue : selectedPull; const item = kind === 'issue' ? selectedIssue : selectedPull;
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item); qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
} }
function setUpdateReplyNextVisibility() {
qs('#send-update-reply-next').hidden = !selectedUpdate || !workSession.checkpointed(selectedUpdate);
}
const issueCommentNext = createCommentNext({ const issueCommentNext = createCommentNext({
post: async (item, body) => { post: async (item, body) => {
const comment = await issueController.comment(item, body); const comment = await issueController.comment(item, body);
@ -963,6 +968,26 @@
failureMessage: 'Comment saved, but Today still needs completion.', failureMessage: 'Comment saved, but Today still needs completion.',
}), }),
}); });
const updateReplyNext = createCommentNext({
queueKind: 'update-reply',
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: (item, result) => {
notificationReplier.saveDraft(item, '');
if (selectedUpdate === item) {
qs('#update-reply').value = '';
if (result.comment) notificationReader.appendReply(result.comment);
qs('#update-reply-status').textContent = result.delivery === 'queued' ?
'Reply queued for background delivery.' : result.delivery === 'saved' ?
'Reply saved for next-launch delivery.' : 'Reply posted.';
}
},
complete: item => completeTodayItem(item, {
successMessage: 'Reply saved. Next Today item opened.',
failureMessage: 'Reply saved, but Today still needs completion.',
}),
});
const closeOfflineIssue = createOfflineIssueClose({ const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message), enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options), completeToday: (item, options) => completeTodayItem(item, options),
@ -3968,6 +3993,31 @@
qs('#update-reply').focus(); qs('#update-reply').focus();
} }
}); });
qs('#send-update-reply-next').addEventListener('click', async () => {
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
qs('#update-reply').focus();
return;
}
const button = qs('#send-update-reply-next');
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
qs('#update-reply-status').textContent = 'Sending reply…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
await updateReplyNext.submit(item, body, operationId);
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
} finally {
button.disabled = false;
sendButton.disabled = false;
}
});
qs('#mark-update-read-next').addEventListener('click', async () => { qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true; qs('#mark-update-read-next').disabled = true;
try { try {

View File

@ -466,7 +466,10 @@
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea> <textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div> <div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div> <div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<button id="send-update-reply" type="button">Send reply</button> <div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
<button id="send-update-reply-next" type="button" hidden>Reply &amp; next</button>
</div>
<div id="update-reply-status" class="small" aria-live="assertive"></div> <div id="update-reply-status" class="small" aria-live="assertive"></div>
</section> </section>
<div class="update-sheet-actions"> <div class="update-sheet-actions">

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-v77'; const CACHE = 'stackchain-dashboard-shell-v78';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -88,6 +88,61 @@ const controller = createCommentNext({{
} }
def test_update_reply_and_next_preserves_notification_identity_online_and_offline():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = Object.assign(new Error('offline'), {{status:503}});
let offline = false;
const controller = createCommentNext({{
queueKind: 'update-reply',
post: (item, body, operationId) => {{
calls.push({{post:[item.notification_id, body, operationId]}});
return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}});
}},
canQueue: () => true,
queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }},
complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }},
}});
(async () => {{
const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}};
const posted = await controller.submit(item, 'Online reply', 'reply-91-a');
offline = true;
const saved = await controller.submit(item, 'Offline reply', 'reply-91-b');
process.stdout.write(JSON.stringify({{posted, saved, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"posted": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 17},
"completed": True,
},
"saved": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{"post": [91, "Online reply", "reply-91-a"]},
{"complete": 91},
{"post": [91, "Offline reply", "reply-91-b"]},
{
"queue": {
"kind": "update-reply",
"notificationId": 91,
"body": "Offline reply",
"operationId": "reply-91-b",
}
},
{"complete": 91},
],
}
def test_comment_and_next_reads_retry_identity_after_the_failed_post(): def test_comment_and_next_reads_retry_identity_after_the_failed_post():
script = f""" script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))}); const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
@ -210,3 +265,20 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
assert "setCommentNextVisibility('pull')" in html assert "setCommentNextVisibility('pull')" in html
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.comment-actions button { min-height:44px;' in html assert '.comment-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_current_today_update_offers_reply_and_next_without_marking_read():
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 '.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-v78" in worker

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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

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-v77" in worker assert "stackchain-dashboard-shell-v78" in worker

View File

@ -35,7 +35,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])) 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-v77" in worker assert "stackchain-dashboard-shell-v78" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -232,6 +232,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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" 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
@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
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-v77" in source assert "stackchain-dashboard-shell-v78" 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-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -365,7 +365,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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source

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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v77';" in service_worker assert "const CACHE = 'stackchain-dashboard-shell-v78';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v77" in source assert "stackchain-dashboard-shell-v78" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source