From 2717f41957533a30e83d056ee6642bc9c768d719 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 18:29:03 +0000 Subject: [PATCH] feat: start work from unread updates (Closes #505) --- README.md | 4 ++ frontend/dashboard.css | 4 +- frontend/dashboard.js | 16 +++++ frontend/index.html | 5 +- frontend/update-ownership.js | 104 ++++++++++++++++++++++++++---- tests/test_my_work.py | 118 +++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fce9a79..edcc1d6 100644 --- a/README.md +++ b/README.md @@ -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 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; +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 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 diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 967182e..4aa8675 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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 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-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 a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .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-filter { flex:1 1 calc(50% - 8px); } .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; } .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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 04f1f12..7e7c887 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -562,6 +562,7 @@ onStatus: message => { qs('#update-reply-status').textContent = message; }, }); const updateOwnership = createUpdateOwnership({ + available: () => createAndStart.available(), claim: item => fetchReviewJson( 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(item.number) + '/claim', @@ -577,6 +578,12 @@ } 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 => { if (!lastContextSnapshot) return; const issues = (lastContextSnapshot.issues || []).filter(candidate => @@ -592,6 +599,12 @@ button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership'; 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({ load: fetchNotificationDetail, @@ -617,6 +630,7 @@ qs('#send-update-reply').disabled = false; qs('#send-update-reply-read-next').disabled = false; qs('#update-ownership-action').hidden = true; + qs('#update-ownership-start').hidden = true; qs('#retry-update-load').hidden = true; setOfflineUpdateControls(false); qs('#keep-update-unread').focus(); @@ -1461,6 +1475,7 @@ qs('#mark-update-read-next').disabled = false; qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next'; qs('#update-ownership-action').disabled = offline; + qs('#update-ownership-start').disabled = offline; qs('#load-older-update-comments').disabled = offline; qs('#update-sheet .detail-defer').inert = offline; } @@ -4275,6 +4290,7 @@ }); qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act()); + qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start()); qs('#retry-update-load').addEventListener('click', () => { if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); }); diff --git a/frontend/index.html b/frontend/index.html index 9a5049f..c40e687 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -523,7 +523,10 @@
- +
+ + +
Open in Gitea diff --git a/frontend/update-ownership.js b/frontend/update-ownership.js index fb18872..eb350b2 100644 --- a/frontend/update-ownership.js +++ b/frontend/update-ownership.js @@ -1,4 +1,6 @@ -function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { +function createUpdateOwnership({ + claim, addToday, available, start, recover, onClaimed, onState, onStartState, +}) { let detail = null; let update = null; let claimedItem = null; @@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { 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) { detail = nextDetail; update = nextUpdate; @@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { busy: false, message: '', }); + startState({ + action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden', + busy: false, + message: '', + }); } function act() { @@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { repository: detail.repository, number: detail.issue.number, })).then(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); + claimed(result); state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' }); return claimedItem; }).catch(error => { if (error?.status === 409) { detail.issue.claimable = false; + startState({ action: 'hidden', busy: false, message: '' }); state({ action: 'hidden', busy: false, @@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) { 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; diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 5d7139f..1d80025 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -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 async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today(): html = await dashboard() assert '' in html assert 'id="update-ownership-action"' in html + assert 'id="update-ownership-start"' in html + assert '>Take ownership & start' in html assert 'hidden aria-describedby="update-sheet-status"' 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 "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 "todayWork.add(item)" 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(): -- 2.43.0