feat: add sequential Following review pass (Closes #1299)
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-23 09:15:57 +00:00
1 parent 6b98eb5cbe
commit b79973fc2e
21 files changed
+267 -41

No files matched your search

+1
View File
@@ -1089,6 +1089,7 @@ textarea { resize: vertical; min-height: 120px; }
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.following-panel h2, .following-panel p { margin:0; }
.following-panel header button, #retry-following { min-height:44px; }
.following-review-action { box-sizing:border-box; width:100%; min-height:44px; }
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
+25 -3
View File
@@ -105,7 +105,10 @@
}
let mobileQueueCounts = {};
let mobilePreparationItems = {};
const followingQueue = attachFollowing(item => searchPreview.open(item));
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
});
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
@@ -5623,7 +5626,7 @@
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
? 'Back to blockers' : searchPreviewReturnKind === 'following' ? 'Back to Following' : 'Back to search';
if (state.status === 'closed') {
searchVoiceReply.cancel();
sheet.classList.remove('open');
@@ -5749,7 +5752,13 @@
searchReplyAttachmentController.clear();
},
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
getSession:() => followingQueue.session() || commandSearchState,
loadMore:() => commandSearch.loadMore(),
onNavigate:item => {
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
},
onOpened:item => followingQueue.previewLoaded(item),
navigationRoot:document,
onState: renderSearchPreview,
});
const searchWeekPlan = createSearchWeekPlan({
@@ -5815,6 +5824,12 @@
schedule: callback => requestAnimationFrame(callback),
});
function closeSearchPreview(navigate = true) {
if (searchPreviewReturnKind === 'following') {
searchPreview.close();
searchPreviewReturnKind = null;
followingQueue.returnToFollowing();
return;
}
if (navigate && taskOverlayHistory.current() === 'search-preview') {
taskOverlayHistory.close();
return;
@@ -6085,6 +6100,13 @@
}
});
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && searchPreviewReturnKind === 'following' &&
qs('#search-preview').classList.contains('open')) {
e.preventDefault();
closeSearchPreview(false);
}
});
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
+64 -7
View File
@@ -11,11 +11,16 @@
function createFollowing(options) {
let generation = 0;
let snapshot = {revision:0, items:[]};
let review = null;
function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
state.degraded = snapshot.degraded === true;
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
if (review?.acknowledged.size) state.reviewSummary = {
reviewed:review.acknowledged.size,
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
};
if (error) state.error = error;
options.render?.(state);
if (status === 'ready') options.onCount?.(
@@ -43,19 +48,47 @@
}
}
async function acknowledge(item) {
const current = snapshot.items.find(candidate =>
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
candidate.updated_at === item?.updated_at);
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
await options.onAcknowledge(current);
current.has_unseen_change = false;
review?.acknowledged.add(current.repository + '#' + current.number + '@' + current.updated_at);
publish('ready');
return true;
}
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, kind:'issue'});
if (item.has_unseen_change === true && typeof options.onAcknowledge === 'function') {
await options.onAcknowledge(item);
item.has_unseen_change = false;
publish('ready');
}
await acknowledge(item);
return true;
}
return {load, open, count:() => snapshot.items.length};
async function startReview() {
const items = snapshot.items
.filter(item => item.has_unseen_change === true)
.map(item => ({...item, kind:'issue'}));
if (!items.length) return false;
review = {items, more:false, active:true, acknowledged:new Set()};
await open(snapshot.items.indexOf(snapshot.items.find(item =>
item.repository === items[0].repository && Number(item.number) === Number(items[0].number))));
return true;
}
function finishReview() {
if (review) review.active = false;
publish('ready');
}
return {
load, open, startReview, previewLoaded:acknowledge, finishReview,
session:() => review?.active ? {items:[...review.items], more:false} : null,
count:() => snapshot.items.length,
};
}
function attachFollowing(onOpen) {
@@ -74,10 +107,16 @@
function render(state) {
const list = query('#following-list');
const status = query('#following-status');
const reviewButton = query('#review-following');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
status.textContent = (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
reviewButton.hidden = unseen === 0;
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
status.textContent = (state.reviewSummary
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
: '') + (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
: 'No watched issues yet. Watch one from Search to keep it here.');
list.innerHTML = state.items.map((item, index) =>
@@ -111,6 +150,12 @@
},
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => {
query('#following-sheet').close();
feature.startReview().catch(() => {
if (!query('#following-sheet').open) query('#following-sheet').showModal();
});
});
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
return {
load:feature.load,
@@ -120,6 +165,18 @@
feature.load().catch(() => {});
return 'opened-following';
},
session:feature.session,
previewLoaded:feature.previewLoaded,
returnToFollowing() {
feature.finishReview();
const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal();
globalThis.requestAnimationFrame?.(() => {
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
target?.focus();
});
return 'returned-following';
},
};
}
+1
View File
@@ -2030,6 +2030,7 @@
<section class="following-panel">
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
<p id="following-status" class="small" role="status" aria-live="polite"></p>
<button id="review-following" class="following-review-action" type="button" hidden>Review new activity</button>
<div id="following-list" class="following-list"></div>
<button id="retry-following" type="button" hidden>Retry</button>
</section>
+3 -2
View File
@@ -120,7 +120,7 @@
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -243,7 +243,7 @@
current = { ...item };
conversation = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
return fetchJson(current).then(async detail => {
if (requestGeneration === generation) {
current = { ...current, ...detail };
if (typeof fetchConversation === 'function') {
@@ -251,6 +251,7 @@
} else {
publish({ status: 'ready', item: current, detail });
}
await onOpened?.({...current});
}
return detail;
}).catch(error => {
+1 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v130';
const CACHE = 'stackchain-dashboard-shell-v131';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;