feat: start work from unread updates (Closes #505)
All checks were successful
CI / lint (pull_request) Successful in 1m9s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 18:29:03 +00:00
parent b26b57d616
commit 2717f41957
6 changed files with 237 additions and 14 deletions

View File

@ -73,6 +73,10 @@ restart, **Resume Today** reopens the saved item (or the next surviving item if
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. **Reply & next** provides the same one-action continuation one without closing or merging it. **Reply & next** provides the same one-action continuation
for the current unread-update conversation. It deliberately leaves the notification unread; for the current unread-update conversation. It deliberately leaves the notification unread;
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission **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 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

View File

@ -244,6 +244,8 @@ textarea { resize: vertical; min-height: 120px; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .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-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-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-ownership-actions button { min-width:0; width:100%; }
.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; }
.update-retry { min-height:44px; width:100%; margin-top:10px; } .update-retry { min-height:44px; width:100%; margin-top:10px; }
@ -446,7 +448,7 @@ textarea { resize: vertical; min-height: 120px; }
.work-filters { width:100%; } .work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); } .work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; } .review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; } .update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; } .issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; } .search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; } #cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }

View File

@ -562,6 +562,7 @@
onStatus: message => { qs('#update-reply-status').textContent = message; }, onStatus: message => { qs('#update-reply-status').textContent = message; },
}); });
const updateOwnership = createUpdateOwnership({ const updateOwnership = createUpdateOwnership({
available: () => createAndStart.available(),
claim: item => fetchReviewJson( claim: item => fetchReviewJson(
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number) + '/claim', '/issues/' + encodeURIComponent(item.number) + '/claim',
@ -577,6 +578,12 @@
} }
return result; return result;
}, },
start: item => {
const outcome = createAndStart.complete(item);
if (outcome === 'started') openRoutedWork(item, qs('#update-ownership-start'));
return outcome;
},
recover: item => openRoutedWork(item, qs('#update-ownership-start')),
onClaimed: item => { onClaimed: item => {
if (!lastContextSnapshot) return; if (!lastContextSnapshot) return;
const issues = (lastContextSnapshot.issues || []).filter(candidate => const issues = (lastContextSnapshot.issues || []).filter(candidate =>
@ -592,6 +599,12 @@
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership'; button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
if (state.message) qs('#update-sheet-status').textContent = state.message; if (state.message) qs('#update-sheet-status').textContent = state.message;
}, },
onStartState: state => {
const button = qs('#update-ownership-start');
button.hidden = state.action === 'hidden';
button.disabled = state.busy;
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
}); });
const notificationReader = createNotificationReader({ const notificationReader = createNotificationReader({
load: fetchNotificationDetail, load: fetchNotificationDetail,
@ -617,6 +630,7 @@
qs('#send-update-reply').disabled = false; qs('#send-update-reply').disabled = false;
qs('#send-update-reply-read-next').disabled = false; qs('#send-update-reply-read-next').disabled = false;
qs('#update-ownership-action').hidden = true; qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#retry-update-load').hidden = true; qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false); setOfflineUpdateControls(false);
qs('#keep-update-unread').focus(); qs('#keep-update-unread').focus();
@ -1461,6 +1475,7 @@
qs('#mark-update-read-next').disabled = false; qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next'; qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#update-ownership-action').disabled = offline; qs('#update-ownership-action').disabled = offline;
qs('#update-ownership-start').disabled = offline;
qs('#load-older-update-comments').disabled = offline; qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline; qs('#update-sheet .detail-defer').inert = offline;
} }
@ -4275,6 +4290,7 @@
}); });
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act()); qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
qs('#retry-update-load').addEventListener('click', () => { qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
}); });

View File

@ -523,7 +523,10 @@
<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">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button> <div class="update-ownership-actions">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership &amp; start</button>
</div>
<button class="share-work-route" type="button">Share</button> <button class="share-work-route" type="button">Share</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button> <button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a> <a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>

View File

@ -1,4 +1,6 @@
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { function createUpdateOwnership({
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
}) {
let detail = null; let detail = null;
let update = null; let update = null;
let claimedItem = null; let claimedItem = null;
@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
onState?.(value); onState?.(value);
} }
function startState(value) {
onStartState?.(value);
}
function claimed(result) {
claimedItem = {
...result,
repository: detail.repository,
kind: 'issue',
notification_id: update?.notification_id,
updated_at: update?.updated_at || result.updated_at,
has_update: true,
};
detail.issue.claimable = false;
onClaimed?.(claimedItem);
startState({ action: 'hidden', busy: false, message: '' });
return claimedItem;
}
function recoverStartedItem(item) {
recover?.(item);
startState({
action: 'hidden',
busy: false,
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
});
state({ action: 'today', busy: false, message: '' });
return 'recovery';
}
function open(nextDetail, nextUpdate) { function open(nextDetail, nextUpdate) {
detail = nextDetail; detail = nextDetail;
update = nextUpdate; update = nextUpdate;
@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
busy: false, busy: false,
message: '', message: '',
}); });
startState({
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
busy: false,
message: '',
});
} }
function act() { function act() {
@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
repository: detail.repository, repository: detail.repository,
number: detail.issue.number, number: detail.issue.number,
})).then(result => { })).then(result => {
claimedItem = { claimed(result);
...result,
repository: detail.repository,
kind: 'issue',
notification_id: update?.notification_id,
updated_at: update?.updated_at || result.updated_at,
has_update: true,
};
detail.issue.claimable = false;
onClaimed?.(claimedItem);
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' }); state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
return claimedItem; return claimedItem;
}).catch(error => { }).catch(error => {
if (error?.status === 409) { if (error?.status === 409) {
detail.issue.claimable = false; detail.issue.claimable = false;
startState({ action: 'hidden', busy: false, message: '' });
state({ state({
action: 'hidden', action: 'hidden',
busy: false, busy: false,
@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
return request; return request;
} }
return { open, act }; function startNow() {
if (request) return request;
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
if (!available()) {
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
startState({ action: 'start', busy: false, message });
return Promise.resolve('full');
}
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
state({ action: 'claim', busy: true, message: '' });
request = Promise.resolve(claim({
repository: detail.repository,
number: detail.issue.number,
})).then(result => {
const item = claimed(result);
const outcome = start(item);
if (outcome === 'started') {
startState({
action: 'hidden',
busy: false,
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
});
state({ action: 'today', busy: false, message: '' });
return outcome;
}
return recoverStartedItem(item);
}).catch(error => {
if (claimedItem) {
return recoverStartedItem(claimedItem);
}
if (error?.status === 409) {
detail.issue.claimable = false;
state({ action: 'hidden', busy: false, message: '' });
startState({
action: 'hidden',
busy: false,
message: 'Someone else claimed or closed this issue. The update is still unread.',
});
return 'conflict';
}
state({ action: 'claim', busy: false, message: '' });
startState({
action: 'start',
busy: false,
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
});
return 'retry';
}).finally(() => { request = null; });
return request;
}
return { open, act, start:startNow };
} }
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership; if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;

View File

@ -176,19 +176,137 @@ controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}
} }
def test_unread_issue_update_claims_and_starts_once_without_marking_it_read():
script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let claims = 0;
let starts = 0;
let finishClaim;
const reconciled = [];
const states = [];
const controller = createUpdateOwnership({{
available: () => true,
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
addToday: () => 'added',
start: item => {{ starts += 1; return item.notification_id === 42 ? 'started' : 'broken'; }},
recover: () => {{ throw new Error('recovery should not run'); }},
onClaimed: item => reconciled.push(item),
onStartState: state => states.push(state),
onState: () => {{}},
}});
controller.open({{
repository:'stackchain/api', title:'Retry deploy',
issue:{{number:7, assignees:[], claimable:true}},
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
const first = controller.start();
const second = controller.start();
if (first !== second || claims !== 1) throw new Error('start was not single-flight');
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
(async () => {{
const result = await first;
process.stdout.write(JSON.stringify({{result, claims, starts, reconciled, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["result"] == "started"
assert output["claims"] == 1
assert output["starts"] == 1
assert len(output["reconciled"]) == 1
assert output["reconciled"][0]["notification_id"] == 42
assert output["reconciled"][0]["has_update"] is True
assert output["states"][-1] == {
"action": "hidden",
"busy": False,
"message": "Assigned, added to Today, and ready to work. The update is still unread.",
}
def test_unread_update_start_checks_today_capacity_before_claiming():
script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let claims = 0;
const states = [];
const controller = createUpdateOwnership({{
available: () => false,
claim: () => {{ claims += 1; return Promise.resolve({{number:7}}); }},
addToday: () => 'added', start: () => 'started',
onStartState: state => states.push(state), onState: () => {{}},
}});
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
(async () => {{
const result = await controller.start();
process.stdout.write(JSON.stringify({{result, claims, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["result"] == "full"
assert output["claims"] == 0
assert output["states"][-1] == {
"action": "start",
"busy": False,
"message": "Today is limited to 5 items. Remove one before taking ownership.",
}
def test_unread_update_opens_owned_issue_when_today_start_throws():
script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let recovered = null;
const states = [];
const controller = createUpdateOwnership({{
available: () => true,
claim: () => Promise.resolve({{number:7, title:'Retry deploy'}}),
addToday: () => 'added',
start: () => {{ throw new Error('storage failed'); }},
recover: item => {{ recovered = item; }},
onStartState: state => states.push(state), onState: () => {{}},
}});
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
(async () => {{
const result = await controller.start();
process.stdout.write(JSON.stringify({{result, recovered, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["result"] == "recovery"
assert output["recovered"]["number"] == 7
assert output["states"][-1] == {
"action": "hidden",
"busy": False,
"message": "Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.",
}
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today(): async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
html = await dashboard() html = await dashboard()
assert '<script src="static/update-ownership.js"></script>' in html assert '<script src="static/update-ownership.js"></script>' in html
assert 'id="update-ownership-action"' in html assert 'id="update-ownership-action"' in html
assert 'id="update-ownership-start"' in html
assert '>Take ownership &amp; start</button>' in html
assert 'hidden aria-describedby="update-sheet-status"' in html assert 'hidden aria-describedby="update-sheet-status"' in html
assert 'const updateOwnership = createUpdateOwnership({' in html assert 'const updateOwnership = createUpdateOwnership({' in html
assert 'available: () => createAndStart.available()' in html
assert 'const outcome = createAndStart.complete(item)' in html
assert "openRoutedWork(item, qs('#update-ownership-start'))" in html
assert 'updateOwnership.open(detail, selectedUpdate)' in html assert 'updateOwnership.open(detail, selectedUpdate)' in html
assert "qs('#update-ownership-action').addEventListener('click'" in html assert "qs('#update-ownership-action').addEventListener('click'" in html
assert "qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start())" in html
assert "lastContextSnapshot.issues" in html assert "lastContextSnapshot.issues" in html
assert "todayWork.add(item)" in html assert "todayWork.add(item)" in html
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
assert '.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden;' in html
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments(): def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():