Merge pull request 'Prefetch the next mobile update during triage' (#758) from timmy/757-prefetch-next-mobile-update into main
This commit is contained in:
commit
82982e5741
|
|
@ -81,7 +81,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
|
||||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
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
|
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.
|
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. During an online Updates pass,
|
||||||
|
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
|
||||||
|
current one is being read. Advancing consumes that account-bound result without another detail
|
||||||
|
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
|
||||||
|
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
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -912,6 +912,7 @@
|
||||||
});
|
});
|
||||||
const notificationReader = createNotificationReader({
|
const notificationReader = createNotificationReader({
|
||||||
load: fetchNotificationDetail,
|
load: fetchNotificationDetail,
|
||||||
|
getScope: () => confirmedOwnerLogin,
|
||||||
loadConversation: fetchNotificationConversation,
|
loadConversation: fetchNotificationConversation,
|
||||||
markRead: markNotificationRead,
|
markRead: markNotificationRead,
|
||||||
acknowledge: acknowledgeNotification,
|
acknowledge: acknowledgeNotification,
|
||||||
|
|
@ -978,6 +979,9 @@
|
||||||
},
|
},
|
||||||
onStatus: message => {
|
onStatus: message => {
|
||||||
qs('#update-sheet-status').textContent = message;
|
qs('#update-sheet-status').textContent = message;
|
||||||
|
if (message === 'Update ready.' && updateTriage.active() && !offlineWorkMode) {
|
||||||
|
notificationReader.prefetch(updateTriage.next());
|
||||||
|
}
|
||||||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||||||
if (message === 'Inbox cleared.') {
|
if (message === 'Inbox cleared.') {
|
||||||
qs('#my-work-action-status').textContent = message;
|
qs('#my-work-action-status').textContent = message;
|
||||||
|
|
@ -1001,6 +1005,7 @@
|
||||||
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
|
notificationReader.prefetch();
|
||||||
qs('#update-triage-progress').hidden = true;
|
qs('#update-triage-progress').hidden = true;
|
||||||
showMobileQueueCompletion('Updates');
|
showMobileQueueCompletion('Updates');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -380,12 +380,27 @@ function createNotificationReader({
|
||||||
loadConversation = null,
|
loadConversation = null,
|
||||||
onConversation = () => {},
|
onConversation = () => {},
|
||||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||||
|
getScope = () => '',
|
||||||
}) {
|
}) {
|
||||||
let selected = null;
|
let selected = null;
|
||||||
let loadVersion = 0;
|
let loadVersion = 0;
|
||||||
let marking = false;
|
let marking = false;
|
||||||
let conversationPager = null;
|
let conversationPager = null;
|
||||||
let offlineHydrated = false;
|
let offlineHydrated = false;
|
||||||
|
let prefetched = null;
|
||||||
|
let prefetchKey = '';
|
||||||
|
|
||||||
|
function prefetch(item) {
|
||||||
|
if (!item) { prefetched = null; prefetchKey = ''; return false; }
|
||||||
|
const notificationId = item?.notification_id;
|
||||||
|
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
|
||||||
|
const key = getScope() + notificationId;
|
||||||
|
if (prefetchKey === key) return prefetched;
|
||||||
|
if (prefetched) return false;
|
||||||
|
prefetchKey = key;
|
||||||
|
prefetched = load(notificationId);
|
||||||
|
return prefetched;
|
||||||
|
}
|
||||||
|
|
||||||
async function advanceAfterRead(items, current, queueing = false) {
|
async function advanceAfterRead(items, current, queueing = false) {
|
||||||
const updated = acknowledgeNotification(items, current.notification_id);
|
const updated = acknowledgeNotification(items, current.notification_id);
|
||||||
|
|
@ -407,10 +422,19 @@ function createNotificationReader({
|
||||||
selected = item;
|
selected = item;
|
||||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||||
const version = ++loadVersion;
|
const version = ++loadVersion;
|
||||||
|
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
|
||||||
|
prefetched = null;
|
||||||
|
prefetchKey = '';
|
||||||
onOpen(item);
|
onOpen(item);
|
||||||
onStatus('Loading update…');
|
if (!preload) onStatus('Loading update…');
|
||||||
try {
|
try {
|
||||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
let detail;
|
||||||
|
if (offlineHydrated) detail = savedDetail;
|
||||||
|
else if (preload) detail = await preload.catch(() => {
|
||||||
|
onStatus('Loading update…');
|
||||||
|
return load(item.notification_id);
|
||||||
|
});
|
||||||
|
else detail = await load(item.notification_id);
|
||||||
if (selected !== item || version !== loadVersion) return false;
|
if (selected !== item || version !== loadVersion) return false;
|
||||||
onDetail(detail);
|
onDetail(detail);
|
||||||
if (createPager && loadConversation && detail.conversation) {
|
if (createPager && loadConversation && detail.conversation) {
|
||||||
|
|
@ -433,6 +457,7 @@ function createNotificationReader({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
|
prefetch,
|
||||||
commentPager() {
|
commentPager() {
|
||||||
return conversationPager;
|
return conversationPager;
|
||||||
},
|
},
|
||||||
|
|
@ -516,7 +541,7 @@ function createNotificationReplier({
|
||||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||||
storage.setItem(keyFor(item), body);
|
storage.setItem(keyFor(item), body);
|
||||||
}
|
}
|
||||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
catch (_error) {}
|
||||||
},
|
},
|
||||||
async submit(item, body, attachment = null) {
|
async submit(item, body, attachment = null) {
|
||||||
if (pending) return false;
|
if (pending) return false;
|
||||||
|
|
@ -542,15 +567,15 @@ function createNotificationReplier({
|
||||||
return { queued:true };
|
return { queued:true };
|
||||||
}
|
}
|
||||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
catch (_error) {}
|
||||||
onStatus('Reply posted. You can mark this update read when ready.');
|
onStatus('Reply posted. You can mark this update read when ready.');
|
||||||
return delivery.confirmed[0];
|
return delivery.confirmed[0];
|
||||||
}
|
}
|
||||||
const result = await post(item.notification_id, body, operationId);
|
const result = await post(item.notification_id, body, operationId);
|
||||||
try { storage.removeItem(keyFor(item)); }
|
try { storage.removeItem(keyFor(item)); }
|
||||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
catch (_error) {}
|
||||||
try { storage.removeItem(operationKeyFor(item)); }
|
try { storage.removeItem(operationKeyFor(item)); }
|
||||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
catch (_error) {}
|
||||||
onStatus('Reply posted. You can mark this update read when ready.');
|
onStatus('Reply posted. You can mark this update read when ready.');
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@
|
||||||
.map(id => byIdentity.get(id));
|
.map(id => byIdentity.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextAvailable() {
|
||||||
|
if (!running || !state) return null;
|
||||||
|
const candidates = available().filter(item => identity(item) !== state.current);
|
||||||
|
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
function finish() {
|
function finish() {
|
||||||
running = false;
|
running = false;
|
||||||
state = null;
|
state = null;
|
||||||
|
|
@ -94,6 +100,7 @@
|
||||||
completeAndNext: advance,
|
completeAndNext: advance,
|
||||||
acceptCompleted: () => advance(false),
|
acceptCompleted: () => advance(false),
|
||||||
keepUnreadAndNext: advance,
|
keepUnreadAndNext: advance,
|
||||||
|
next: nextAvailable,
|
||||||
items: () => state ? available().slice() : [],
|
items: () => state ? available().slice() : [],
|
||||||
end: finish,
|
end: finish,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
# One-ahead Updates prefetch stays in the core reader so transitions can reuse its in-flight request.
|
||||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
assert len(first.runtime_gzip_bytes) <= 97 * 1024
|
||||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||||
|
|
|
||||||
|
|
@ -4871,6 +4871,76 @@ reader.open(original[0], original).then(() =>
|
||||||
assert output["result"]["next"]["notification_id"] == 43
|
assert output["result"]["next"]["notification_id"] == 43
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
|
||||||
|
const loaded = [], details = [], statuses = [];
|
||||||
|
const releases = {{}};
|
||||||
|
const reader = buildMyWork.createNotificationReader({{
|
||||||
|
load: id => {{
|
||||||
|
loaded.push(id);
|
||||||
|
if (id === 1) return Promise.resolve({{id}});
|
||||||
|
return new Promise(resolve => {{ releases[id] = resolve; }});
|
||||||
|
}},
|
||||||
|
markRead: async () => {{}},
|
||||||
|
onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
|
||||||
|
onStatus: status => statuses.push(status), onClose: () => {{}},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await reader.open(items[0]);
|
||||||
|
const first = reader.prefetch(items[1]);
|
||||||
|
const duplicate = reader.prefetch(items[1]);
|
||||||
|
const rejected = reader.prefetch(items[2]);
|
||||||
|
const advancing = reader.open(items[1]);
|
||||||
|
await Promise.resolve();
|
||||||
|
releases[2]({{id:2}});
|
||||||
|
await advancing;
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
loaded, details, same:first === duplicate, rejected, statuses,
|
||||||
|
}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"loaded": [1, 2],
|
||||||
|
"details": [1, 2],
|
||||||
|
"same": True,
|
||||||
|
"rejected": False,
|
||||||
|
"statuses": ["Loading update…", "Update ready.", "Update ready."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
let scope = 'timmy';
|
||||||
|
const loaded = [];
|
||||||
|
const reader = buildMyWork.createNotificationReader({{
|
||||||
|
load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
|
||||||
|
getScope: () => scope,
|
||||||
|
markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
|
||||||
|
onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
|
||||||
|
onClose: () => {{}},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await reader.open({{notification_id:1}});
|
||||||
|
await reader.prefetch({{notification_id:2}});
|
||||||
|
scope = 'alexander';
|
||||||
|
const opened = await reader.open({{notification_id:2}});
|
||||||
|
process.stdout.write(JSON.stringify({{loaded, opened}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}
|
||||||
|
|
||||||
|
|
||||||
def test_notification_reader_acknowledges_once_and_opens_next_update():
|
def test_notification_reader_acknowledges_once_and_opens_next_update():
|
||||||
script = f"""
|
script = f"""
|
||||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,27 @@ process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_triage_exposes_only_the_next_surviving_snapshot_item():
|
||||||
|
result = run_session("""
|
||||||
|
const values = new Map();
|
||||||
|
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||||
|
const session = createSession({
|
||||||
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||||
|
getLogin:()=> 'timmy', getItems:()=>items,
|
||||||
|
onOpen:()=>{}, onProgress:()=>{}, onFinish:()=>{},
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
const first = session.next()?.notification_id;
|
||||||
|
items = [1,3,4].map(notification_id => ({notification_id}));
|
||||||
|
const afterRemoval = session.next()?.notification_id;
|
||||||
|
session.keepUnreadAndNext();
|
||||||
|
const afterAdvance = session.next();
|
||||||
|
process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdvance || null}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user