Merge pull request 'Complete review requests without leaving mobile Updates triage' (#776) from timmy/775-update-review-handoff into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
timmy 2026-08-13 22:01:54 +00:00
commit 35f3999787
8 changed files with 223 additions and 3 deletions

View File

@ -408,7 +408,7 @@ textarea { resize: vertical; min-height: 120px; }
.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-more-actions { margin-top:14px; } .update-more-actions { margin-top:14px; }
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-decision-bar { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; } .update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
.update-retry { min-height:44px; width:100%; margin-top:10px; } .update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }

View File

@ -193,6 +193,7 @@
let draft = null; let draft = null;
let reviewFiles = []; let reviewFiles = [];
let selectedReviewHead = ''; let selectedReviewHead = '';
let reviewHandoffSubmitted = false;
let activeInlineTarget = null; let activeInlineTarget = null;
let bulkConfirmationPending = false; let bulkConfirmationPending = false;
let bulkMarkPending = false; let bulkMarkPending = false;
@ -943,7 +944,7 @@
const updateDecision = createUpdateDecisionTransaction({ const updateDecision = createUpdateDecisionTransaction({
controls: [ controls: [
qs('#keep-update-unread'), qs('#mark-update-read-next'), qs('#keep-update-unread'), qs('#mark-update-read-next'),
qs('#focus-update-reply'), qs('#toggle-update-more'), qs('#review-update-now'), qs('#focus-update-reply'), qs('#toggle-update-more'),
], ],
status: qs('#update-gesture-status'), status: qs('#update-gesture-status'),
}); });
@ -985,6 +986,7 @@
qs('#update-ownership-action').hidden = true; qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true; qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true; qs('#create-update-follow-up').hidden = true;
qs('#review-update-now').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();
@ -999,6 +1001,7 @@
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported; qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
qs('#mute-update-next').hidden = !detail.mute_supported; qs('#mute-update-next').hidden = !detail.mute_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type); qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
qs('#review-update-now').hidden = !updateReviewHandoff.eligible(detail, selectedUpdate);
if (offlineWorkMode) { if (offlineWorkMode) {
setOfflineUpdateControls(true); setOfflineUpdateControls(true);
} else { } else {
@ -1056,6 +1059,36 @@
showUpdateTriageOutcome(outcome); showUpdateTriageOutcome(outcome);
}, },
}); });
const updateReviewHandoff = createUpdateReviewHandoff({
openReview: item => {
qs('#update-sheet').classList.remove('open');
reviewHandoffSubmitted = false;
openReviewSheet(item, qs('#review-update-now'),
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null);
},
restoreUpdate: () => {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
qs('#update-sheet').classList.add('open');
qs('#review-update-now').focus();
},
admitRead: notificationId => offlineWorkMode ?
notificationReadOutbox.enqueueDurably(notificationId) : markNotificationRead(notificationId),
advance: source => {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
notificationReader.acceptReadAndNext(lastMyWork, source);
if (updateTriage.active()) updateTriage.acceptCompleted();
reviewHandoffSubmitted = false;
},
});
qs('#review-update-now').addEventListener('click', () => updateReviewHandoff.begin(selectedUpdate));
qs('#review-kept-updates').addEventListener('click', () => { qs('#review-kept-updates').addEventListener('click', () => {
qs('#mobile-queue-sheet').close(); qs('#mobile-queue-sheet').close();
updateTriage.reviewKept(keptUpdateIdentities); updateTriage.reviewKept(keptUpdateIdentities);
@ -4034,6 +4067,10 @@
} }
function closeReviewSheet(navigate = true) { function closeReviewSheet(navigate = true) {
if (updateReviewHandoff.active()) {
updateReviewHandoff.cancel();
return;
}
if (navigate && createWorkRoute.parse(window.location.hash)) { if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close(); workRoute.close();
return; return;
@ -5767,6 +5804,17 @@
)) return; )) return;
const button = qs('#submit-review'); const button = qs('#submit-review');
button.disabled = true; button.disabled = true;
if (updateReviewHandoff.active() && reviewHandoffSubmitted) {
try {
await updateReviewHandoff.complete();
} catch (error) {
qs('#review-submit-status').textContent = 'Review submitted, but the update is still unread. ' +
error.message + ' Retry to continue.';
button.disabled = false;
button.focus();
}
return;
}
if (offlineReview) { if (offlineReview) {
const queuedTodayReview = reviewingActiveTodayItem(); const queuedTodayReview = reviewingActiveTodayItem();
qs('#review-submit-status').textContent = 'Queueing review safely…'; qs('#review-submit-status').textContent = 'Queueing review safely…';
@ -5784,6 +5832,11 @@
draftFingerprint: localStorage.getItem(draft.storageKey) || '', draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '', progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
}); });
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
if (queuedTodayReview) { if (queuedTodayReview) {
const advanced = completeTodayItem(selectedReview, { const advanced = completeTodayItem(selectedReview, {
successMessage: 'Review queued. Next Today item opened.', successMessage: 'Review queued. Next Today item opened.',
@ -5812,6 +5865,11 @@
expected_head_sha: selectedReviewHead, expected_head_sha: selectedReviewHead,
comments: snapshot.comments, comments: snapshot.comments,
}); });
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
const canContinueToMerge = createReviewController.prepareMergeContinuation({ const canContinueToMerge = createReviewController.prepareMergeContinuation({
storage: localStorage, storage: localStorage,
item, item,

View File

@ -814,6 +814,7 @@
<div class="update-decision-bar" aria-label="Update decisions"> <div class="update-decision-bar" aria-label="Update decisions">
<button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button> <button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>
<button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button> <button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button>
<button id="review-update-now" type="button" hidden>Review now</button>
<button id="focus-update-reply" type="button">Reply</button> <button id="focus-update-reply" type="button">Reply</button>
<button id="toggle-update-more" type="button" aria-expanded="false">More</button> <button id="toggle-update-more" type="button" aria-expanded="false">More</button>
</div> </div>
@ -1088,6 +1089,7 @@
<script src="static/mobile-work-entry.js"></script> <script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script> <script src="static/mobile-queue-launcher.js"></script>
<script src="static/update-triage-session.js"></script> <script src="static/update-triage-session.js"></script>
<script src="static/update-review-handoff.js"></script>
<script src="static/update-read-position.js"></script> <script src="static/update-read-position.js"></script>
<script src="static/update-triage-launcher.js"></script> <script src="static/update-triage-launcher.js"></script>
<script src="static/update-triage-gesture.js"></script> <script src="static/update-triage-gesture.js"></script>

View File

@ -77,6 +77,7 @@ const SHELL = [
BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js', BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/update-triage-session.js', BASE + 'static/update-triage-session.js',
BASE + 'static/update-review-handoff.js',
BASE + 'static/update-read-position.js', BASE + 'static/update-read-position.js',
BASE + 'static/update-triage-launcher.js', BASE + 'static/update-triage-launcher.js',
BASE + 'static/update-triage-gesture.js', BASE + 'static/update-triage-gesture.js',

View File

@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReviewHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
let source = null;
let completing = null;
function eligible(detail, item = detail) {
const merged = { ...(item || {}), ...(detail || {}) };
return Boolean(
merged.is_review && merged.update_reason === 'Review requested' &&
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
Number.isInteger(Number(merged.notification_id))
);
}
function reviewItem(item) {
return {
...item,
kind: 'review',
is_review: true,
key: item.key || item.repository + '#' + item.number,
};
}
function begin(item) {
if (!eligible(item) || source) return false;
source = item;
options.openReview(reviewItem(item));
return true;
}
function cancel() {
if (!source || completing) return false;
const item = source;
source = null;
options.restoreUpdate?.(item);
return true;
}
function complete() {
if (!source) return Promise.resolve(false);
if (completing) return completing;
const item = source;
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
options.advance(item);
source = null;
return true;
}).finally(() => { completing = null; });
return completing;
}
return {
eligible,
begin,
cancel,
complete,
active: () => Boolean(source),
source: () => source,
};
});

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",

View File

@ -847,6 +847,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/update-triage-session.js", "/dashboard/static/update-triage-session.js",
"/dashboard/static/update-review-handoff.js",
"/dashboard/static/update-read-position.js", "/dashboard/static/update-read-position.js",
"/dashboard/static/update-triage-launcher.js", "/dashboard/static/update-triage-launcher.js",
"/dashboard/static/update-triage-gesture.js", "/dashboard/static/update-triage-gesture.js",

View File

@ -0,0 +1,96 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
HANDOFF = Path(__file__).resolve().parents[1] / "frontend" / "update-review-handoff.js"
def run_handoff(script):
source = f"const createHandoff = require({json.dumps(str(HANDOFF))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_review_handoff_only_admits_review_requested_pull_updates():
result = run_handoff("""
const handoff = createHandoff({openReview:()=>{}, admitRead:async()=>{}, advance:()=>{}});
const cases = [
{is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41},
{is_review:true, update_reason:'Mentioned', subject_type:'Pull', repository:'acme/app', number:7, notification_id:42},
{is_review:true, update_reason:'Review requested', subject_type:'Issue', repository:'acme/app', number:7, notification_id:43},
{is_review:false, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:44},
];
process.stdout.write(JSON.stringify(cases.map(item => handoff.eligible(item))));
""")
assert result == [True, False, False, False]
def test_review_handoff_preserves_update_on_cancel_and_advances_after_read_admission():
result = run_handoff("""
(async () => {
const events = [];
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41, title:'Ship it'};
const handoff = createHandoff({
openReview:item=>events.push(['open', item.repository, item.number]),
restoreUpdate:item=>events.push(['restore', item.notification_id]),
admitRead:async id=>events.push(['read', id]),
advance:()=>events.push(['advance']),
});
handoff.begin(update);
handoff.cancel();
handoff.begin(update);
const completed = await handoff.complete();
process.stdout.write(JSON.stringify({events, completed, active:handoff.active()}));
})().catch(error => { console.error(error); process.exit(1); });
""")
assert result == {
"events": [
["open", "acme/app", 7],
["restore", 41],
["open", "acme/app", 7],
["read", 41],
["advance"],
],
"completed": True,
"active": False,
}
def test_review_handoff_keeps_current_update_when_read_admission_fails():
result = run_handoff("""
(async () => {
const events = [];
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41};
const handoff = createHandoff({
openReview:()=>events.push('open'),
admitRead:async()=>{ events.push('read'); throw new Error('queue unavailable'); },
advance:()=>events.push('advance'),
});
handoff.begin(update);
let message = '';
try { await handoff.complete(); } catch (error) { message = error.message; }
process.stdout.write(JSON.stringify({events, message, active:handoff.active()}));
})().catch(error => { console.error(error); process.exit(1); });
""")
assert result == {"events": ["open", "read"], "message": "queue unavailable", "active": True}
@pytest.mark.anyio
async def test_dashboard_wires_review_requested_update_into_existing_review_workspace():
html = await dashboard()
assert '<script src="static/update-review-handoff.js"></script>' in html
assert 'id="review-update-now"' in html
assert "updateReviewHandoff.begin(selectedUpdate)" in html
assert "await updateReviewHandoff.complete()" in html
assert "updateReviewHandoff.cancel()" in html
assert "updateReviewHandoff.eligible(detail" in html