feat: add sequential Following review pass (Closes #1299)
This commit is contained in:
1 parent
6b98eb5cbe
commit
b79973fc2e
21 files changed
+267
-41
No files matched your search
@@ -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
@@ -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
@@ -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';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in new issue
Block a user