(async function(){
const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window }));
await workspaceLifecycle.optionalReady;
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
const progressiveMobileDockHandoff = window.stackchainProgressiveMobileDock?.handoff?.();
window.stackchainProgressiveMobileDock?.stop?.();
window.stackchainProgressiveMyWork?.stop();
const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message;
const fmt = (d) => new Date(d).toLocaleString();
const appBadge = createMobileAppBadge({
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
container:qs('#app-badge-setting'), navigator, storage:localStorage,
serviceWorker:navigator.serviceWorker,
});
appBadge.start();
const cardPlanning = createCardPlanning(document);
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
entries: [
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
],
});
mobileComposerViewport.start();
const issueDetailPanel = qs('#issue-sheet .issue-sheet-panel');
const mobileIssueDetailNavigation = createMobileIssueDetailNavigation({
root:issueDetailPanel,
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-issue-section]')).map(button => [button.dataset.issueSection, button])),
targets:{
overview:qs('#issue-overview'),
conversation:qs('#issue-conversation'),
reply:qs('#issue-comment'),
actions:qs('#issue-planning'),
},
planning:qs('#issue-planning'),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileIssueDetailNavigation.start();
const pullDetailPanel = qs('#pull-sheet .pull-sheet-panel');
const mobilePullDetailNavigation = createMobileIssueDetailNavigation({
root:pullDetailPanel,
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-pull-section]')).map(button => [button.dataset.pullSection, button])),
targets:{
overview:qs('#pull-overview'),
conversation:qs('#pull-conversation'),
reply:qs('#pull-comment'),
review:qs('#pull-review'),
},
beforeNavigate:{review(target) { target.open = true; }},
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobilePullDetailNavigation.start();
[
[qs('.app-menu'), qs('#app-menu-toggle')],
[qs('.work-settings'), qs('#work-settings-toggle')],
].forEach(([disclosure, launcher]) => {
mobileLaunch.createDisclosure({disclosure, launcher}).start();
});
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
let savedState = {};
try {
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
} catch (e) {
savedState = {};
}
panels.forEach((panel) => {
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
panel.open = savedState[panel.dataset.panelKey];
}
panel.addEventListener('toggle', () => {
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
try {
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
} catch (_) {}
});
});
const mobileTaskButtons = Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
);
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
const attentionInterruption = qs('#attention-interruption');
function renderAttentionInterruption() {
const pending = timer.attentionInterruption();
attentionInterruption.hidden = !pending;
return pending;
}
function selectMobileQueue(name) {
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
renderAttentionInterruption();
timerView.render();
qs('[data-work-filter="' + name + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
async function recoverMobileDelivery(item) {
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
const authored = item.kind === 'authored-outbox';
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
}
let queueCounts = {};
let preparationItems = {};
let renderMobileQueuePresentation = () => {};
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
}, {
onCount:(count, items) => {
appBadge.reconcile('following', count, true);
queueCounts.following = count;
queueCounts.followingUnavailable = false;
preparationItems.following = items.filter(item => item.has_unseen_change === true);
renderMobileQueuePresentation();
mobileStartDay.render();
},
onStatus:status => {
if (status === 'loading') return;
queueCounts.followingUnavailable = status === 'error';
renderMobileQueuePresentation();
mobileStartDay.render();
},
onReviewComplete:() => mobileStartDay.completePhase('following'),
});
function bindDetailWatch(kind, button, status) {
const feature=createDetailWatch({fetchJson:fetchReviewJson,refreshFollowing:()=>followingQueue.load(),onState:(state,watching,error)=>{
button.disabled=['loading','watching','unwatching'].includes(state);
button.textContent=watching?'Stop watching':'Watch '+(kind==='pull'?'pull request':'issue');
status.textContent=state==='ready'?(watching?'Watching · available in Following.':''):
searchPreviewWatchStatus({status:state==='error'?'watch-error':state,error,detail:{kind}});
}});
button.addEventListener('click',()=>feature.toggle().catch(()=>{}));
return feature;
}
const issueDetailWatch = bindDetailWatch('issue',qs('#watch-issue-detail'),qs('#issue-watch-status'));
const pullDetailWatch = bindDetailWatch('pull',qs('#watch-pull-detail'),qs('#pull-watch-status'));
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
activate: recoverMobileDelivery,
beforeOpen: () => selectMobileQueue('draft'),
onComplete: () => {
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
},
});
mobileDeliveryRecovery.start();
function openFiledFollowUp() {
selectMobileQueue('filed');
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
if (!target) {
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
return 'empty';
}
const index = lastMyWork.indexOf(target.item);
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
}
const mobileQueueLauncher = createMobileQueueLauncher({
openDelivery: () => mobileDeliveryRecovery.open(),
openHumanGates: () => openHumanGates(),
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
openFollowing:followingQueue.open,
openFiled: openFiledFollowUp,
selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: announceWork,
getCounts: () => queueCounts,
openFindWork: () => qs('#find-work').click(),
rows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
nextAction: qs('#mobile-queue-next-action'),
activeList: qs('#mobile-queue-active-list'),
planningList: qs('#mobile-queue-planning-list'),
allList: qs('#mobile-queue-all-list'),
activeSection: qs('#mobile-queue-active-list').parentElement,
});
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
renderMobileQueuePresentation();
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
function openMobileStartDay() {
followingQueue.load().catch(() => {});
const state = mobileStartDay.state();
mobileStartDay.render();
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
qs('#mobile-start-day-action').focus();
}
const mobileFirstTask = createMobileFirstTask({
getLogin: () => confirmedOwnerLogin,
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
isTodayActive: () => workSession.checkpointed(),
});
mobileFirstTask.start();
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
getEligibleCount: () => activeMyWork.length,
getPreparationState: () => mobileStartDay.state(),
queueLauncher: mobileQueueLauncher,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
prepareToday: openMobileStartDay,
shouldActivate: () => mobileFirstTask.required(),
openActivation: () => mobileFirstTask.open(),
findWork: () => qs('#find-work').click(),
});
const mobileStartDay = createMobileStartDay({
getCounts: () => queueCounts,
getPhaseItems: () => preparationItems,
getLogin: () => confirmedOwnerLogin,
openQueue: name => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
return name === 'find' ? qs('#find-work').click() :
name === 'following' ? (queueCounts.followingUnavailable ? followingQueue.open() :
followingQueue.review()) : mobileQueueLauncher.open(name);
},
onHandoff: current => {
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) sheet.showModal();
qs('#mobile-start-day-action').focus();
},
elements: {
summary: qs('#mobile-start-day-summary'),
phases: qs('#mobile-start-day-phases'),
action: qs('#mobile-start-day-action'),
finish: qs('#finish-mobile-start-day'),
},
});
mobileStartDay.start();
qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish());
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
mobileStartDay.render();
const next = mobileQueueLauncher.recommend();
qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : '');
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
const row = qs('[data-mobile-queue="' + next.name + '"]');
row.setAttribute('data-recommended', 'true');
qs('#mobile-queue-sheet').showModal();
row.focus();
}
let keptUpdateIdentities = [];
function showUpdateTriageOutcome(outcome) {
const box = qs('#mobile-update-outcome');
const review = qs('#review-kept-updates');
keptUpdateIdentities = outcome.keptIdentities;
box.hidden = false;
qs('#mobile-update-outcome-summary').textContent = outcome.reviewed + ' reviewed · ' + outcome.resolved +
' marked read · ' + outcome.kept + ' kept unread';
review.hidden = !outcome.kept;
review.textContent = 'Review ' + outcome.kept + ' kept unread';
if (!outcome.kept && mobileStartDay.completePhase('update')) return;
showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept);
if (outcome.kept) review.focus();
}
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
queueSheet: qs('#mobile-queue-sheet'),
queueClose: qs('#close-mobile-queues'),
queueRows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
queueCounts: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
),
queueBadge: qs('#mobile-queue-count'),
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
name === 'find' ? qs('#find-work').click() :
name === 'week' ? openWeekPlanner(row) :
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
detour:() => timerView,
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => refreshTomorrowQueueSummary(),
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']}));
return observer;
},
});
mobileTaskDock.start();
if (['work', 'queues'].includes(progressiveMobileDockHandoff?.lastTask)) {
mobileTaskDock.select(progressiveMobileDockHandoff.lastTask);
}
if (progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open) {
qs('#mobile-queue-sheet').showModal();
}
const mobileTodayActions = qs('#mobile-today-actions');
createMobileTodayCommandBar({
more:qs('[data-mobile-today-more]'),
sheet:mobileTodayActions,
close:qs('[data-mobile-today-actions-close]'),
firstAction:qs('[data-work-session-previous]', mobileTodayActions),
actionButtons:Array.from(mobileTodayActions.querySelectorAll(
'[data-work-session-previous], [data-work-session-next], [data-mobile-today-update], [data-mobile-today-blocked], [data-today-break-open], [data-work-session-adjust-plan]'
)),
endAction:qs('[data-mobile-today-end]', mobileTodayActions),
existingEndControl:qs('#end-today-session'),
hud:qs('[data-mobile-today-hud]'),
dock:qs('#mobile-task-dock'),
style:document.documentElement.style,
});
const mobileInsights = createMobileInsights({
root: qs('#insights-sheet'),
launcher: qs('#open-insights'),
closeButton: qs('#close-insights'),
dock: qs('#mobile-task-dock'),
hud: qs('[data-mobile-today-hud]'),
backgrounds: [qs('header'), qs('#my-work')],
history: window.history,
mediaQuery: window.matchMedia('(max-width: 600px)'),
detour:() => timerView,
});
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
const hasProgressiveWorkFilter = WORK_FILTERS.includes(progressiveWorkHandoff?.selectedFilter);
let selectedWorkFilter = hasProgressiveWorkFilter ? progressiveWorkHandoff.selectedFilter : 'all';
let selectedFiledView = 'needs-review';
let selectedWorkMilestone = 'all';
let queueFindQuery = '';
let savedWorkFilter = null;
let launchFilterResolved = hasProgressiveWorkFilter;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (!hasProgressiveWorkFilter && ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
selectedWorkFilter = savedFilter;
savedWorkFilter = savedFilter;
launchFilterResolved = true;
}
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {}
let lastMyWork = [];
let lastDrafts = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let workPagination = {};
let agendaChecking = false;
let agendaReplan = null;
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let offlineReview = false;
let selectedUpdate = null;
let selectedUpdateDetail = null;
let followUpSourceUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
let delegatedParentReview = null;
let dismissedChecklistBody = null;
let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null;
let issueConversation = null;
let issueTrigger = null;
let issueEditHistoryActive = false;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let pendingIssueFilingIntent = 'create-and-assign';
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let reviewHandoffSubmitted = false;
let activeInlineTarget = null;
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
let rR = null;
function rRC() {
if (rR) return rR;
rR = createReleaseReceipt({
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
releaseNode:qs('#release-receipt-link'),
listNode:qs('#release-watchlist'),
openPull:openPullSheet,
});
rR.bind();
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
qs('#refresh-release-receipt').addEventListener('click', () => rR.refresh().catch(error => {
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
}));
qs('#dismiss-release-receipt').addEventListener('click', () => rR.dismiss());
return rR;
}
const completedFiledReview = createCompletedFiledReview({
storage: localStorage,
getLogin() { return planningOwnerLogin; },
});
const filedHistoryTabs = createFiledHistoryTabs({
root:qs('#filed-history-tabs'), review:completedFiledReview,
onSelect:view => { selectedFiledView = view; renderMyWork(); },
});
let completedFiledSyncFlight = null;
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
let rolloverReviewPlan = null;
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayRollover = createTodayRollover();
let planningTomorrow = false;
let latestTodayPlan = null;
const tomorrowPlan = createTomorrowPlan({
fetchJson:fetchReviewJson,
localDate:todayRollover.localDate,
timeZone:todayRollover.timeZone,
storage:localStorage,
getLogin:() => planningOwnerLogin,
});
const weekPlan = createWeekPlan({
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
storage:localStorage,getLogin:() => planningOwnerLogin,coordinator:outboxCoordinator,
});
function openWeekPlanner(trigger) { planningTomorrow=false; return weekFlow.open(trigger); }
function renderTomorrowQueueSummary(value) {
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
}
function syncPendingTomorrow() {
weekFlow.resumePull()&&weekFlow.flushPull();
if (!tomorrowPlan.pending()) return Promise.resolve(false);
return tomorrowPlan.flush().then(saved => {
renderTomorrowQueueSummary(saved);
qs('#my-work-action-status').textContent = saved.ids.length ?
`Tomorrow saved for ${saved.plan_date} without changing Today.` :
`Tomorrow cleared for ${saved.plan_date}.`;
return saved;
}).catch(error => {
const conflict = tomorrowPlan.conflict();
if (conflict) renderTomorrowQueueSummary({ids:[],sync_pending:false,conflict:true});
qs('#mobile-tomorrow-summary').textContent = conflict ? 'Conflict · review required' : qs('#mobile-tomorrow-summary').textContent;
qs('#my-work-action-status').textContent = conflict ?
'Another device changed Tomorrow. Review it.' :
`${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`;
return false;
});
}
async function refreshTomorrowQueueSummary() {
try {
const loaded = await tomorrowPlan.load();
if (tomorrowPlan.conflict()) qs('#mobile-tomorrow-summary').textContent = 'Conflict · review required';
else renderTomorrowQueueSummary(loaded);
return true;
} catch (_error) {
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
return false;
}
}
async function openTomorrowPlanner(trigger) {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return false;
}
trigger.disabled = true;
qs('#mobile-tomorrow-summary').textContent = 'Loading Tomorrow…';
try {
const loaded = await tomorrowPlan.load();
renderTomorrowQueueSummary(loaded);
planningTomorrow = true;
openPlanToday(trigger);
qs('#my-work-action-status').textContent = '';
return true;
} catch (error) {
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`;
return false;
} finally {
trigger.disabled = false;
}
}
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
const weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id);
const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs,
getItem:weekItem,openItem:openRoutedWork,
openPlanner:openPlanToday,setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v),
escapeHtml,escapeAttribute:escAttr,todayWork,
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
x:currentTodayProgressTarget});
const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
onSave:async byDate=>{Object.entries(byDate).forEach(([planDate,startTimes])=>weekPlan.stageStartTimes(planDate,startTimes));await weekPlan.flush();},
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
coordinator:outboxCoordinator,
onRemoteIds:ids=>{if(!planningOwnerLogin||!todayWork.replace(ids))return;refreshMyWorkView();warmTodayOffline();},
onRemotePlan:plan=>{
if(!planningOwnerLogin)return;
latestTodayPlan=plan;
weekFlow.promote(plan).finally(() => {
if (window.location.hash !== '#/my-work/start-day') return;
window.history.replaceState({}, '', '#/my-work/today');
openMobileStartDay();
});
todayWork.replacePlanning({capacity_minutes:plan.capacity_minutes??null,estimates:plan.estimates||{}});
const s=todayRollover.reviewState(plan);
if(!['stale','legacy'].includes(s)){
rolloverReviewPlan=null;
qs('#plan-today').textContent='Plan Today';
return;
}
rolloverReviewPlan=plan;
qs('#plan-today').textContent='Review new day';
qs('#my-work-action-status').textContent='Review yesterday’s unfinished work before starting today.';
const marker=`stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if(!localStorage.getItem(marker)){
localStorage.setItem(marker,'1');
setTimeout(()=>{if(!workSession.checkpointed())openPlanToday(qs('#plan-today'));},0);
}
},
onStatus: (state, detail = {}) => {
const status = qs('#today-sync-status');
status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'recovered' ? `Today queue recovered · discarded ${detail.discarded} unreadable device record${detail.discarded === 1 ? '' : 's'}.` :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Today sync unavailable · changes stay on this device.')))));
},
});
todaySync.startLifecycle({ window, document });
tomorrowPlan.startLifecycle({
windowObject:window,
documentObject:document,
check:() => Promise.all([
syncPendingTomorrow(),
weekPlan.pending() ? weekPlan.flush().catch(() => false) : false,
latestTodayPlan ? weekFlow.promote(latestTodayPlan) : false,
]),
});
const todayHandoff = createTodayHandoff({
storage:localStorage,
getLogin:() => planningOwnerLogin,
identity:item => todayWork.identity(item),
items:() => [...todayMyWork, ...activeMyWork],
todayWork,
todaySync,
});
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
onComplete:() => refreshMyWorkView(),
});
const promptTodayHandoff = () => todayHandoffView.open();
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt, handoff) => {
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
},
onExpire: (ids, handoffs) => {
if (handoffs.length) {
todayHandoff.capture(handoffs);
todayHandoffView.reset();
}
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
setTimeout(promptTodayHandoff, 0);
},
});
const laterSync = createLaterSync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteRecords: records => {
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
refreshMyWorkView();
},
onStatus: (state, detail = {}) => {
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
(state === 'expired' ? `Later edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Later sync unavailable · changes stay on this device.'))));
},
});
laterSync.startLifecycle({ window, document });
document.addEventListener('visibilitychange', () => {
if (document.hidden) interruptionPrompt.background();
else interruptionPrompt.foreground();
if (!document.hidden) {
refreshMyWorkView();
if (updateTriage.active()) updateTriage.reconcile();
}
});
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
throw error;
}
return payload;
}
const humanGatesOnChange = (snapshot, state)=>{
queueCounts.gate = snapshot.pending_count;
appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true);
queueCounts.gateUnavailable = state.available === false;
preparationItems.gate = snapshot.items;
mobileTaskDock.updateQueues(queueCounts);
if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']});
mobileStartDay.render();
};
const humanGates = progressiveHumanGatesHandoff?.controller || createHumanGates({
storage:localStorage,
getLogin:()=>planningOwnerLogin,
getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine,
location:window.location,
fetchJson:fetchReviewJson,
onChange:humanGatesOnChange,
nodes:{
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
status:qs('#human-gates-status'), panel:qs('#human-gates'),
detail:qs('#human-gate-detail'),
pendingTab:qs('#human-gates-pending'), historyTab:qs('#human-gates-history'),
},
});
progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
humanGates.setOnChange?.(humanGatesOnChange);
const openHumanGates = () => humanGates.open().catch(error => {
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
});
if (!progressiveHumanGatesHandoff) {
qs('#open-human-gates').addEventListener('click', openHumanGates);
qs('#close-human-gates').addEventListener('click', () => {
qs('#human-gates').hidden = true;
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
});
qs('#human-gates-list').addEventListener('click', event => {
const card = event.target.closest('[data-human-gate-id]');
if (!card) return;
humanGates.select(card.dataset.humanGateId);
});
qs('#human-gates-pending').addEventListener('click', () => humanGates.showPending());
qs('#human-gates-history').addEventListener('click', () => humanGates.showHistory().catch(error => {
qs('#human-gates-status').textContent = error.message;
}));
qs('#human-gate-detail').addEventListener('click', event => {
const recovery = event.target.closest('[data-gate-recover]');
if (recovery) {
recovery.disabled = true;
humanGates.recoverDecision().catch(error => {
qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.';
recovery.disabled = false;
});
return;
}
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
humanGates.submitDecision(decision).catch(error => {
if (!error?.targetSelector) qs('#human-gates-status').textContent = error.message;
});
});
}
if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {});
if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates();
function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight;
const pending = completedFiledReview.pending();
const options = pending.length ? {
method:'POST',
headers:{Accept:'application/json', 'Content-Type':'application/json'},
body:JSON.stringify({ receipts:pending }),
} : { headers:{Accept:'application/json'} };
completedFiledSyncFlight = fetchReviewJson('api/v1/completed-filed-reviews', options)
.then(snapshot => {
const changed = completedFiledReview.adopt(snapshot);
if (lastContextSnapshot && changed) {
lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
}
if (pending.length) qs('#my-work-action-status').textContent =
'Completed Filed review saved to account.';
return true;
})
.catch(() => {
if (pending.length) qs('#my-work-action-status').textContent =
'Acknowledged on this device · sync pending.';
return false;
})
.finally(() => { completedFiledSyncFlight = null; });
return completedFiledSyncFlight;
}
let commentActions = { isOwned: () => false, wire: () => {} };
function loadMentionCandidates(repository, query) {
return fetchReviewJson(
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
{headers:{Accept:'application/json'}},
);
}
const [issueMentions, pullMentions, updateMentions, todayProgressMentions] = [
['issue-comment',()=>selectedIssue?.repository],
['pull-comment',()=>selectedPull?.repository],
['update-reply',()=>selectedUpdate?.repository],
['today-progress-body',()=>currentTodayProgressTarget()?.repository],
].map(([id,getRepository]) => {
const controller = createMentionComposer({
textarea:qs('#'+id), listbox:qs('#'+id+'-mentions'),
status:qs('#'+id+'-mention-status'), getRepository, loadCandidates:loadMentionCandidates,
});
controller.start();
return controller;
});
async function api(url) {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Shared work request failed.');
error.unavailable = response.status === 404;
throw error;
}
return payload;
}
let reviewController = null;
let wrapPreference = null;
const issueController = createIssueSheet({
fetchJson: fetchReviewJson,
storage: localStorage,
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
});
function overdueAgendaItems() {
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
}
function renderAgendaReplan() {
const state = agendaReplan.snapshot();
qs('#agenda-replan-controls').hidden = !state.active;
if (!state.active) return;
qs('#agenda-replan-progress').textContent =
'Overdue deadline ' + (state.index + 1) + ' of ' + state.total + ' · ' + (agendaReplan.current()?.key || '');
}
function openAgendaReplanCurrent() {
const item = agendaReplan.current();
if (!item) {
closeIssueSheet(false);
renderAgendaReplan();
qs('#my-work-action-status').textContent = 'Overdue sweep complete. Agenda updated.';
return;
}
renderAgendaReplan();
openIssueSheet(item, qs('#start-agenda-replan'));
}
agendaReplan = createAgendaReplan({
update: async (item, dueDate) => {
const confirmed = await issueController.updateDueDate(item, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, item.repository, item.number, confirmed.due_date
);
paintMyWork(lastContextSnapshot);
return confirmed;
},
});
qs('#start-agenda-replan').addEventListener('click', () => {
const overdue = overdueAgendaItems();
agendaReplan.start(overdue);
openAgendaReplanCurrent();
});
async function runAgendaReplan(action) {
const result = await action();
if (!result.ok) {
qs('#agenda-replan-progress').textContent = result.error + ' Current deadline retained; retry.';
return;
}
openAgendaReplanCurrent();
}
qs('#agenda-replan-keep').addEventListener('click', () => runAgendaReplan(() => agendaReplan.keep()));
qs('#agenda-replan-tomorrow').addEventListener('click', () => runAgendaReplan(() => agendaReplan.tomorrow()));
qs('#agenda-replan-choose').addEventListener('click', () =>
runAgendaReplan(() => agendaReplan.choose(qs('#agenda-replan-date').value))
);
qs('#agenda-replan-cancel').addEventListener('click', () => {
agendaReplan.cancel();
closeIssueSheet(false);
renderAgendaReplan();
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
});
StackchainAgendaCalendar.mountAgendaCalendarExport({
qs,
getItems:() => agendaMyWork(activeMyWork),
escapeHtml,
onDone:result => {
qs('#my-work-action-status').textContent = result === 'shared' ?
'Agenda calendar snapshot shared.' : 'Agenda calendar snapshot downloaded.';
},
});
let searchReplyAttachmentTarget = null;
let searchReplyRestoreGeneration = 0;
let restoringSearchReplyPhotos = false;
const searchReplyDraftStore = 'indexedDB' in window ? createSearchReplyDraftStore({
indexedDB:window.indexedDB,
getOwnerLogin:() => confirmedOwnerLogin,
}) : null;
const sameSearchReplyTarget = (left, right) => left && right && left.kind === right.kind &&
left.repository === right.repository && Number(left.number) === Number(right.number);
async function persistSearchReplyPhotos() {
const target = searchReplyAttachmentTarget ? { ...searchReplyAttachmentTarget } : null;
if (!target || !searchReplyDraftStore || !searchReplyAttachmentController || restoringSearchReplyPhotos) return;
try {
const attachments = await searchReplyAttachmentController.serialize();
await searchReplyDraftStore.save(target, attachments);
void refreshPhotoDraftInbox();
} catch (error) {
qs('#search-preview-reply-status').textContent = error?.message ||
'Photos could not be saved. They remain in this preview; retry before leaving.';
throw error;
}
}
async function restoreSearchReplyPhotos(target) {
if (!searchReplyDraftStore || !target) return;
const generation = ++searchReplyRestoreGeneration;
try {
const attachments = await searchReplyDraftStore.load(target);
if (generation !== searchReplyRestoreGeneration || !sameSearchReplyTarget(target, searchReplyAttachmentTarget)) return;
if (attachments?.length) {
restoringSearchReplyPhotos = true;
try { searchReplyAttachmentController.restore(attachments); }
finally { restoringSearchReplyPhotos = false; }
updateSearchReplyButtons(searchReplyAttachmentController.state());
qs('#search-preview-reply-status').textContent = 'Saved photo evidence restored.';
}
} catch (error) {
if (generation === searchReplyRestoreGeneration) {
qs('#search-preview-reply-status').textContent = error?.message || 'Saved photo evidence could not be restored.';
}
}
}
const updateSearchReplyButtons = state => {
const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim());
qs('#send-search-preview-reply').disabled = !enabled;
qs('#send-search-preview-reply-next').disabled = !enabled;
};
const searchReplyAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#search-reply-attachment'),
inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')],
preview: qs('#search-reply-attachment-preview'),
image: qs('#search-reply-attachment-image'),
meta: qs('#search-reply-attachment-meta'),
remove: qs('#remove-search-reply-attachment'),
tray: qs('#search-reply-attachment-tray'),
earlier: qs('#move-search-reply-attachment-earlier'),
later: qs('#move-search-reply-attachment-later'),
note: qs('#search-reply-attachment-note'),
noteLabel: qs('#search-reply-attachment-note-label'),
status: qs('#search-preview-reply-status'),
readyMessage: 'Photo ready to send with this Search reply.',
removedMessage: 'Photo removed. Your Search reply is unchanged.',
onChange: state => {
updateSearchReplyButtons(state);
persistSearchReplyPhotos().catch(() => {});
},
onCheckpoint:() => persistSearchReplyPhotos(),
editor: {
document,
edit: qs('#edit-search-reply-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
const target = searchReplyAttachmentTarget;
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.'));
}
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) +
'/preview/attachments?kind=' + encodeURIComponent(target.kind),
{
method:'POST',
headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
body:issueAttachment.multipart(payload),
},
);
},
});
const issueAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#issue-attachment'),
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
preview: qs('#issue-attachment-preview'),
image: qs('#issue-attachment-image'),
meta: qs('#issue-attachment-meta'),
remove: qs('#remove-issue-attachment'),
tray: qs('#issue-attachment-tray'),
earlier: qs('#move-issue-attachment-earlier'),
later: qs('#move-issue-attachment-later'),
note: qs('#issue-attachment-note'),
noteLabel: qs('#issue-attachment-note-label'),
status: qs('#issue-comment-status'),
onChange:() => conversationPhotoDrafts.checkpoint('issue').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('issue'),
editor: {
document,
edit: qs('#edit-issue-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': payload.operation_id,
},
body: issueAttachment.multipart(payload),
},
);
},
});
const pullAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#pull-attachment'),
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
preview: qs('#pull-attachment-preview'),
image: qs('#pull-attachment-image'),
meta: qs('#pull-attachment-meta'),
remove: qs('#remove-pull-attachment'),
tray: qs('#pull-attachment-tray'),
earlier: qs('#move-pull-attachment-earlier'),
later: qs('#move-pull-attachment-later'),
note: qs('#pull-attachment-note'),
noteLabel: qs('#pull-attachment-note-label'),
status: qs('#pull-comment-status'),
onChange:() => conversationPhotoDrafts.checkpoint('pull').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('pull'),
editor: {
document,
edit: qs('#edit-pull-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: { Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body: issueAttachment.multipart(payload),
},
);
},
});
const updateReplyAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#update-reply-attachment'),
inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')],
preview: qs('#update-reply-attachment-preview'),
image: qs('#update-reply-attachment-image'),
meta: qs('#update-reply-attachment-meta'),
remove: qs('#remove-update-reply-attachment'),
tray: qs('#update-reply-attachment-tray'),
earlier: qs('#move-update-reply-attachment-earlier'),
later: qs('#move-update-reply-attachment-later'),
note: qs('#update-reply-attachment-note'),
noteLabel: qs('#update-reply-attachment-note-label'),
status: qs('#update-reply-status'),
onChange:() => conversationPhotoDrafts.checkpoint('update').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('update'),
readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.',
editor: {
document,
edit: qs('#edit-update-reply-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => fetchReviewJson(
'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments',
{ method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body:issueAttachment.multipart(payload) },
),
});
const conversationPhotoDraftStore = createConversationReplyDraftStore({
indexedDB:window.indexedDB, getOwnerLogin:() => confirmedOwnerLogin,
});
const photoDraftLane = (controller, status) => ({ controller, onError:error => {
qs(status).textContent = error.message + ' Your photos remain here; retry before leaving.';
} });
const conversationPhotoDrafts = createConversationPhotoDrafts({
store:conversationPhotoDraftStore,
onChange:() => { void refreshPhotoDraftInbox(); },
lanes:{
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
},
});
const todayProgressPhotos = createTodayProgressPhotos({
qs, document, issueAttachment, fetchJson:fetchReviewJson,
createStore:createConversationReplyDraftStore, createDrafts:createConversationPhotoDrafts,
getLogin:() => confirmedOwnerLogin,
});
const voiceTranscriptStore = createVoiceTranscriptStore();
const voiceOptions = {
qs, Recognition:window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore:voiceTranscriptStore, getLogin:() => confirmedOwnerLogin,
};
const [issueVoiceReply, pullVoiceReply, updateVoiceReply, searchVoiceReply, todayProgressVoice] = [
['issue-comment', '#issue-comment'], ['pull-comment', '#pull-comment'],
['update-reply', '#update-reply'], ['search-reply', '#search-preview-reply'],
['today-progress', '#today-progress-body'],
].map(([kind, draftSelector]) => mountVoiceConversation({...voiceOptions, kind, draftSelector}));
function conversationVoiceTarget(kind, item) {
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
}
function searchConversationVoiceTarget(item) {
return 'search:' + conversationVoiceTarget(item?.kind, item);
}
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')],
preview: qs('#create-issue-attachment-preview'),
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),
remove: qs('#remove-create-issue-attachment'),
tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'),
note: qs('#create-issue-evidence-note'),
noteLabel: qs('#create-issue-evidence-note-label'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
editor: {
document,
edit: qs('#edit-create-issue-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'),
redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'),
arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'),
reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'),
apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
});
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
let checklistPromotion = null;
let issueOwnerPicker = null;
let issueTemplatePicker = null;
let issueFilingMetadata = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
attachmentStore: unfiledAttachmentStore,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
createUnfiledDraftSync(
unfiledCaptures, fetchReviewJson
);
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
review:r=>loadIssueFilingMetadata(r.repository,r)
.then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates),
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
backgroundIssueSync = createBackgroundIssueSync({
store: backgroundIssueStore, fetchJson: fetchReviewJson,
});
backgroundIssueSync.requestSync = async () => {
if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable');
const registration = await navigator.serviceWorker.ready;
if (!registration.sync) throw new Error('Background Sync unavailable');
await registration.sync.register('stackchain-issue-outbox-v1');
};
}
const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const filingReview = createIssueFilingReview({
sheet: qs('#issue-filing-review'),
confirmButton: qs('#confirm-issue-filing'),
backButton: qs('#back-to-issue-edit'),
evidenceList: qs('#issue-filing-review-evidence'),
evidenceEmpty: qs('#issue-filing-review-evidence-empty'),
evidencePreview: qs('#issue-filing-review-evidence-preview'),
evidenceImage: qs('#issue-filing-review-evidence-image'),
evidencePosition: qs('#issue-filing-review-evidence-position'),
evidenceFilename: qs('#issue-filing-review-evidence-filename'),
evidenceNote: qs('#issue-filing-review-evidence-note'),
repository: qs('#issue-filing-review-repository'),
intent: qs('#issue-filing-review-intent'),
issueType: qs('#issue-filing-review-template'),
title: qs('#issue-filing-review-title'),
body: qs('#issue-filing-review-body'),
metadata: qs('#issue-filing-review-metadata'),
blockerList: qs('#issue-filing-review-blockers'),
status: qs('#issue-filing-review-status'),
document,
createObjectURL: blob => URL.createObjectURL(blob),
revokeObjectURL: url => URL.revokeObjectURL(url),
onConfirm: admitReviewedIssue,
});
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
mergeChecklistConflict: mergeChecklistConflict,
});
const todayProgress = createTodayProgress({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
admit:message => authoredOutbox.enqueueDurably(message),
});
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
});
const notificationReadOutbox = createNotificationReadOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => {
issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records);
notificationReadOutbox.reconcileBackground(records);
}).catch(() => {});
}
const shareParams = new URLSearchParams(location.search);
const appShortcut = mobileAppShortcuts.createController({
search: location.search,
continueWork: () => mobileWorkEntry.open(),
newIssue: () => openCreateIssueSheet(),
agenda: () => openAgendaSession(),
});
const sharedLaunch = {
title: shareParams.get('title') || '',
text: shareParams.get('text') || '',
url: shareParams.get('url') || '',
};
const sharedImageMarker = shareParams.get('shared') || '';
let sharedImageHandled = false;
const commentActionFeatures = createFeatureLoader({
document,
urls: {
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
},
});
const actionHydrator = createConversationActionHydrator({
load: () => commentActionFeatures.load('comment-actions'),
activate: () => {
commentActions = createCommentActions({
fetchJson: fetchReviewJson,
getLogin: () => confirmedOwnerLogin,
confirmDelete: message => window.confirm(message),
});
return commentActions;
},
});
const issueCaptureFeatures = createFeatureLoader({
document,
urls: {
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
},
});
let sharedLaunchState = null;
let sharedLaunchHandled = false;
let voiceIssueCapture = null;
async function ensureIssueCapture() {
return await issueCaptureFeatures.run('issue-capture', {
trigger: qs('#new-issue'), status: qs('#my-work-action-status'),
}, () => {
if (!issueCapture) {
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
voiceIssueCapture = createVoiceIssueCapture({
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
elements: {
root:qs('#voice-issue-capture'), start:qs('#start-voice-issue-capture'),
stop:qs('#stop-voice-issue-capture'), review:qs('#voice-issue-review'),
transcript:qs('#voice-issue-transcript'), append:qs('#append-voice-issue-transcript'),
replace:qs('#replace-with-voice-issue-transcript'), discard:qs('#discard-voice-issue-transcript'),
status:qs('#voice-issue-status'), title:qs('#create-issue-title'), body:qs('#create-issue-body'),
},
transcriptStore:createVoiceTranscriptStore(),
getLogin:()=>confirmedOwnerLogin,
});
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
issueTemplatePicker = issueCapture.bindTemplatePicker({
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
status:qs('#create-issue-template-status'), document,
getRepository:()=>qs('#create-issue-repository').value,
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
}, {
getDraft:currentIssueCaptureDraft,
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
});
issueFilingMetadata = issueCapture.bindFilingMetadata({
document,
labelList:qs('#create-issue-label-list'),
labelStatus:qs('#create-issue-label-status'),
milestoneSelect:qs('#create-issue-milestone'),
milestoneStatus:qs('#create-issue-milestone-status'),
templateStatus:qs('#create-issue-template-status'),
getRepository:()=>qs('#create-issue-repository').value,
}, issueTemplatePicker);
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
checklistPromotion = createIssueCapture.createChecklistPromotion({
issueController, issueCapture,
clearAttachments:()=>createIssueAttachmentController.clear(),
onLinked:(promotion, confirmed) => {
if (selectedIssue?.repository === promotion.item.repository &&
selectedIssue?.number === promotion.item.number) {
applyIssueContent(promotion.item, promotion.detail, confirmed);
qs('#issue-sheet').classList.add('open');
}
},
onStatus:message=>{ qs('#my-work-action-status').textContent = message; },
});
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
sharedLaunchHandled = true;
}
});
}
if (Object.values(sharedLaunch).some(Boolean) || sharedImageMarker) await ensureIssueCapture();
const pullWorkflowFeatures = createFeatureLoader({
document,
urls: {
'pull-workflow': document.querySelector('meta[name="stackchain-feature-pull-workflow"]')?.content || '',
},
});
let pullController = null;
async function ensurePullWorkflow(trigger = null) {
return await pullWorkflowFeatures.run('pull-workflow', {
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
}, () => {
if (!pullController) {
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView });
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
const continuing = workSession.checkpointed(item);
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
closePullSheet();
if (continuing) return await completeOwnershipExitToday(item);
paintMyWork(lastContextSnapshot);
return false;
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
rRC();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
if (!wrapPreference) {
wrapPreference = createReviewController.createWrapPreference({
storage: localStorage,
mobile: window.matchMedia('(max-width: 600px)').matches,
});
}
});
}
function restoreReleaseReceipt() {
const account = String(confirmedOwnerLogin || '').trim().toLowerCase();
if (!account) return;
try {
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
} catch (_error) { return; }
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
}
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
const photoDraftInbox = createPhotoDraftInbox({
conversation:conversationPhotoDraftStore,
search:searchReplyDraftStore,
});
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
let offlineStorageReady = await offlineWorkStore.ready();
function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status');
const retry = qs('#retry-offline-today');
const visible = offlineWorkStore.enabled() && status.total > 0;
container.hidden = !visible;
if (!visible) return;
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
(status.pending ? ' · saving ' + status.pending : '') +
(status.failed ? ' · retry ' + status.failed : '');
retry.hidden = status.failed === 0;
}
const offlineToday = createOfflineToday({
loadDetail: async item => {
if (item.is_review || item.kind === 'pull') {
if (!await ensurePullWorkflow()) throw new Error('Pull workspace is unavailable.');
return item.is_review ? reviewController.load(item) : pullController.load(item);
}
return issueController.load(item);
},
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
onStatus: renderOfflineTodayStatus,
});
function warmTodayOffline() {
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
return Promise.resolve();
}
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
}
let findWorkNavigation = null;
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
onPagination: pagination => {
availablePagination = pagination;
qs('#load-more-available').hidden = !pagination.has_more;
},
onStatus: message => { qs('#find-work-status').textContent = message; },
onSelection: state => {
qs('#batch-find-work-actions').hidden = !state.active;
qs('#select-find-work').hidden = state.active;
qs('#find-work-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
qs('#claim-selected-work').disabled = state.count === 0;
findWorkNavigation?.sync({ selectedCount:state.count });
renderAvailableIssues(findWorkController.items());
},
});
findWorkNavigation = createMobileFindWorkNavigation({
document,
history:window.history,
eventTarget:window,
controller:findWorkController,
todayWork,
openFit:openFindWorkEstimateReview,
});
findWorkNavigation.start();
let findWorkSearchTimer = null;
function updateFindWorkMatchStatus() {
const query = findWorkController.query();
qs('#find-work-match-status').textContent = query ?
availablePagination.total + ' match' + (availablePagination.total === 1 ? '' : 'es') +
' for “' + query + '”.' : '';
}
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
setClock(); setInterval(setClock, 1000);
async function fetchLiveSnapshot(revisions = {}, { signal } = {}) {
const query = createContextPoller.buildRevisionQuery(revisions);
const res = await fetch('api/v1/live' + (query ? '?' + query : ''), {
headers: { Accept: 'application/json' },
signal,
});
if (!res.ok) {
const error = new Error('HTTP ' + res.status);
error.retryAfterMs = createContextPoller.retryAfterMs(res.headers.get('Retry-After'));
throw error;
}
return res.json();
}
async function markNotificationRead(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
method: 'PATCH',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
return payload;
}
async function acknowledgeNotification(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/acknowledge', { method: 'POST', headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Acknowledging the update failed.');
return payload;
}
async function muteNotification(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/mute', { method: 'POST', headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || 'Muting future updates failed.');
error.muted = payload.muted === true;
throw error;
}
return payload;
}
async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
return payload;
}
async function fetchNotificationPage(page) {
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
return payload;
}
async function fetchNotificationDetail(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
return payload;
}
async function fetchNotificationConversation(notificationId, page) {
const pageQuery = Number.isInteger(page) ? '&page=' + encodeURIComponent(page) : '';
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/conversation?limit=20' + pageQuery, {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
return payload;
}
async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
return payload;
}
async function postNotificationReply(notificationId, body, operationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({ body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
markRead: markNotificationsRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationSelection = createNotificationSelection({
limit: 50,
onChange: () => {
bulkConfirmationPending = false;
renderMyWork();
},
});
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
lastNotifications = items;
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
onPagination: pagination => {
notificationPagination = pagination;
appBadge.reconcile('updates', pagination.total, true);
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !pagination.has_more;
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const workPager = createWorkPager({
load: fetchWorkPage,
onItems: (stream, items) => {
if (!lastContextSnapshot) return;
if (stream === 'issue' || stream === 'filed') lastContextSnapshot.issues = items;
else lastContextSnapshot.pull_requests = items;
paintMyWork(lastContextSnapshot);
},
onPagination: pagination => {
workPagination = pagination;
updateWorkPaginationControls();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
authoredOutbox,
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',
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
if (result === 'added') {
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
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 =>
candidate.repository !== item.repository || candidate.number !== item.number
);
lastContextSnapshot = { ...lastContextSnapshot, issues:issues.concat(item) };
paintMyWork(lastContextSnapshot);
},
onState: state => {
const button = qs('#update-ownership-action');
button.hidden = state.action === 'hidden';
button.disabled = state.busy;
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 notificationUndo = createDashboardNotificationUndo({
restore: requestNotificationUnread,
getItems: () => lastMyWork,
setItems: items => { lastMyWork = items; },
getNotifications: () => lastNotifications,
setNotifications: items => { lastNotifications = items; },
refresh: refreshMyWorkView,
select: qs,
});
const updateReadPosition = createUpdateReadPosition({
panel: qs('#update-sheet .update-sheet-panel'),
jump: qs('#jump-update-new-activity'),
});
const mobileUpdateDetailNavigation = createMobileUpdateDetailNavigation({
root:qs('#update-sheet .update-sheet-panel'),
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-update-section]')).map(button => [button.dataset.updateSection, button])),
targets:{
conversation:qs('#update-conversation'),
context:qs('#update-subject-context'),
reply:qs('#update-reply-workspace'),
},
replyComposer:qs('#update-reply'),
jumpToNewActivity:() => updateReadPosition.jump(),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileUpdateDetailNavigation.start();
const mobileReviewDetailNavigation = createMobileReviewDetailNavigation({
root:qs('#review-sheet .review-sheet-panel'),
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-review-section]')).map(button => [button.dataset.reviewSection, button])),
targets:{
overview:qs('#review-overview'),
files:qs('#review-files-workspace'),
feedback:qs('#review-feedback'),
history:qs('#review-history-workspace'),
},
summaryComposer:qs('#review-summary'),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileReviewDetailNavigation.start();
const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') });
const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') });
const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') });
const workDetailIdentity = (kind, item) => kind + ':' + item.key;
qs('#jump-update-new-activity').addEventListener('click', () => updateReadPosition.jump());
qs('#focus-update-reply').addEventListener('click', () => {
const composer = qs('#update-reply');
composer.scrollIntoView({ behavior:'smooth', block:'center' });
qs('#update-reply').focus({ preventScroll:true });
});
qs('#toggle-update-more').addEventListener('click', event => {
const more = qs('.update-more-actions');
more.open = !more.open;
event.currentTarget.setAttribute('aria-expanded', String(more.open));
if (more.open) more.scrollIntoView({ behavior:'smooth', block:'end' });
});
const updateDecision = createUpdateDecisionTransaction({
controls: [
qs('#keep-update-unread'), qs('#mark-update-read-next'),
qs('#review-update-now'), qs('#focus-update-reply'), qs('#toggle-update-more'),
],
status: qs('#update-gesture-status'),
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
getScope: () => confirmedOwnerLogin,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
acknowledge: acknowledgeNotification,
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
selectedUpdate = item;
void conversationPhotoDrafts.switchTo('update',
{ kind:'update', notificationId:item.notification_id }, () => selectedUpdate === item);
void updateVoiceReply.open('update:' + item.notification_id);
selectedUpdateDetail = null;
updateReadPosition.open(String(item.notification_id));
mobileUpdateDetailNavigation.reset();
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('.update-more-actions').open = false;
qs('#toggle-update-more').setAttribute('aria-expanded', 'false');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
qs('#update-comments').textContent = '';
qs('#update-conversation-status').textContent = '';
qs('#retry-update-conversation').hidden = true;
qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
qs('#open-update-gitea').href = item.url || '#';
qs('#update-reply').value = notificationReplier.loadDraft(item);
qs('#update-reply-status').textContent = '';
qs('#send-update-reply').disabled = false;
qs('#send-update-reply-read-next').disabled = false;
qs('#acknowledge-update-next').hidden = true;
qs('#mute-update-next').hidden = true;
qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true;
qs('#review-update-now').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: async detail => {
selectedUpdateDetail = detail;
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
qs('#mute-update-next').hidden = !detail.mute_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
qs('#review-update-now').hidden = !updateReviewHandoff.eligible(detail, selectedUpdate);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true;
},
onConversation: renderUpdateConversation,
onConversationStatus: message => {
qs('#update-conversation-status').textContent = message;
qs('#retry-update-conversation').hidden = !message.startsWith('Conversation temporarily unavailable.');
},
onItems: items => {
const readId = selectedUpdate?.notification_id;
lastMyWork = items;
if (Number.isInteger(readId)) {
lastNotifications = lastNotifications.filter(item => item.id !== readId);
}
refreshMyWorkView();
},
onStatus: 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.');
if (message === 'Inbox cleared.') {
qs('#my-work-action-status').textContent = message;
if (!mobileStartDay.completePhase('update')) showMobileQueueCompletion('Updates');
}
},
onClose: () => closeUpdateSheet(false),
});
const updateTriage = createUpdateTriageSession({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)),
onOpen: item => notificationReader.open(
item,
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null
),
onProgress: state => {
const progress = qs('#update-triage-progress');
progress.hidden = false;
progress.textContent = 'Update ' + state.index + ' of ' + state.total +
(state.reason ? ' · ' + state.reason : '');
},
onFinish: outcome => {
notificationReader.prefetch();
qs('#update-triage-progress').hidden = true;
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('#mobile-queue-sheet').close();
updateTriage.reviewKept(keptUpdateIdentities);
});
const updateTriageLauncher = createUpdateTriageLauncher({
selectUpdates: () => selectMobileQueue('update'),
discover: () => api('api/v1/notifications/snapshot'),
applySnapshot: snapshot => {
lastNotifications = snapshot.items || [];
notificationPager.reset({page:1, total:snapshot.total || 0, has_more:false});
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
isUpdatesSelected: () => selectedWorkFilter === 'update',
hasMore: () => Boolean(notificationPagination.has_more),
hasCheckpoint: () => updateTriage.resumable(),
resume: () => updateTriage.resume(),
start: () => updateTriage.start(),
announce: announceWork,
});
function openUpdateTriage() {
return updateTriageLauncher.open();
}
function routedWorkItem(item) {
if (item?.has_update && item.kind === 'update') {
return { ...item, kind:'update' };
}
return { ...item, kind:item?.is_review ? 'review' : item?.kind };
}
function openRoutedWork(item, trigger, options = {}) {
if (!item) return;
if (offlineWorkMode) {
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
const savedUpdate = item.kind === 'update' && item.has_update;
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
return;
}
if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger, savedDetail);
} else if (item.is_review) {
reviewTrigger = trigger;
openReviewSheet(item, trigger, savedDetail);
} else if (item.kind === 'pull') {
pullTrigger = trigger;
openPullSheet(item, trigger, savedDetail);
} else if (savedUpdate) {
updateTrigger = trigger;
notificationReader.open(item, savedDetail).then(opened => {
if (opened && selectedUpdate === item) {
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
}
});
} else {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
}
return;
}
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
else if (item.is_review) reviewTrigger = trigger;
else if (item.kind === 'issue') issueTrigger = trigger;
else if (item.kind === 'pull') pullTrigger = trigger;
workRoute.open(routedWorkItem(item), options);
}
function navigateWorkSection(kind, section) {
if (['issue', 'filed'].includes(kind)) {
mobileIssueDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'pull') {
mobilePullDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'update') {
mobileUpdateDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'review') {
mobileReviewDetailNavigation.navigate(section, { focus:false });
}
}
async function openRoutedWorkSection(item) {
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
}
const workRoute = createWorkRoute.createController({
location: window.location,
history: window.history,
eventTarget: window,
resolve: route => {
const params = new URLSearchParams({ kind:route.kind });
if (route.kind === 'update') params.set('notification_id', route.notification_id);
else {
params.set('repository', route.repository);
params.set('number', route.number);
}
return api('api/v1/work-route?' + params.toString());
},
onResolving: () => {
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = 'Loading shared work item…';
},
onOpen: async item => {
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
await openRoutedWorkSection(item);
const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
},
onSection: (section, options) => {
const route = createWorkRoute.parse(window.location.hash);
if (options.restore) navigateWorkSection(route?.kind, section);
},
onQueue: openWorkQueueRoute,
onClose: () => {
if (selectedIssue) closeIssueSheet(false);
if (selectedPull) closePullSheet(false);
if (selectedReview) closeReviewSheet(false);
if (selectedUpdate) closeUpdateSheet(true, false);
},
onInvalid: () => {
qs('#retry-work-route').hidden = true;
window.history.replaceState(null, '', window.location.pathname + window.location.search);
closeOpenWorkSheets();
qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.';
qs('#my-work').scrollIntoView({ block:'start' });
},
onError: () => {
qs('#my-work-action-status').textContent = 'Could not load shared work item. The link is preserved; retry when connected.';
qs('#retry-work-route').hidden = false;
},
});
workRoute.start();
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
issueVoiceReply.cancel();
pullVoiceReply.cancel();
updateVoiceReply.cancel();
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
.forEach(selector => mobileComposerViewport.close(qs(selector)));
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
qs(selector).classList.remove('open')
);
selectedIssue = null;
selectedIssueDetail = null;
selectedPull = null;
selectedPullDetail = null;
selectedReview = null;
selectedUpdate = null;
}
function openWorkSessionItem(item) {
timerView.open(workIdentity(item), workSession.checkpointed(item));
openRoutedWork(item, null, { replace:true });
}
const sessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onError: () => {
qs('#my-work-action-status').textContent =
'Session recovery could not be saved on this device. You can keep working.';
},
});
const agendaSessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
key: 'stackchain.agenda-session.v1',
onError: () => {
qs('#my-work-action-status').textContent =
'Agenda progress could not be saved on this device. You can keep working.';
},
});
let todaySessionSync = null;
let todayLockScreen = null;
const timer = createTodayTimer({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onChange: snapshot => {
todaySessionSync?.publish(timer.sessionSnapshot());
queueMicrotask(() => {
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
});
},
});
const timerView = createTodayTimerView({
timer,
isActive: () => workSession.checkpointed(),
queryAll: s => document.querySelectorAll(s),
formatEstimate: formatPlanMinutes,
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
onReopen: identity => {
taskOverlayHistory.leave();
selectTodayWork();
const item = [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity);
if (!workSession.reopen(item)) workSession.resume(item);
},
onResume: identity => {
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
if (!workSession.resume(item)) resumeTodaySession();
},
onComplete: identity => {
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
return completeTodayItem(item);
},
onCapture:() => openCreateIssueSheet(),
});
window.stackchainTodayTimerView = timerView;
mobileInsights.start();
todaySessionSync = attachTodaySessionHandoff({
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
items:() => [...todayMyWork, ...activeMyWork],
identity:item => todayWork.identity(item),
selectToday:() => selectTodayWork(),
startItem:item => workSession.start(item),
announce:message => { qs('#my-work-action-status').textContent = message; },
renderTimer:() => timerView.render(),
});
renderAttentionInterruption();
qs('#return-to-today').addEventListener('click', () => {
const returned = timer.returnFromAttention();
renderAttentionInterruption();
if (!returned) return;
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === returned.identity);
if (item) workSession.reopen(item);
else continueTodaySession();
timerView.render();
});
const interruptionPrompt = createTodayInterruptionPrompt({
timer,
sheet: qs('#today-interruption-sheet'),
description: qs('#today-interruption-description'),
getItemLabel: identity => {
const item = [...todayMyWork, ...activeMyWork].find(entry => todayWork.identity(entry) === identity);
return item?.title || '';
},
onResolved: () => timerView.render(),
});
window.addEventListener('pagehide', () => interruptionPrompt.background());
interruptionPrompt.restore();
document.querySelectorAll('[data-today-interruption]').forEach(button => {
button.addEventListener('click', () =>
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todaySummaryView = setupTodaySummary({
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
});
todaySummaryView.resume();
const todayWrapUpView = setupTodayWrapUp({ todayWork, tomorrowPlan, todaySync, qs, escapeHtml,
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
todayRecapView['completeReplan']();
refreshMyWorkView();
warmTodayOffline();
renderTomorrowQueueSummary();
syncPendingTomorrow();
todaySummaryView.open(workedItems, tomorrowItems);
},
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
(actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
button.textContent = active ? 'Later today & next' : 'Later today';
});
document.querySelectorAll('[data-detail-defer-preset=tomorrow]').forEach(button => {
button.textContent = active ? 'Tomorrow & next' : 'Tomorrow';
});
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
button.textContent = active ? 'Choose date & time & next' : 'Choose date & time';
});
}
function updateWorkSessionActions() {
const active = workSession.active();
const startWorkSession = qs('#start-work-session');
startWorkSession.hidden = active;
startWorkSession.textContent = selectedWorkFilter === 'agenda' ?
(agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda') : 'Start work';
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
!todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
todayProgressView.update();
updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
}
const workSession = createWorkSession({
getItems: () => selectedWorkFilter === 'today' ? todayMyWork :
selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' :
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
checkpointedEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem,
onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
timerView.update(state, runway);
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
button.disabled = !state.can_previous;
});
document.querySelectorAll('[data-work-session-next]').forEach(button => {
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
});
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
button.hidden = !workSession.checkpointed();
});
todayProgressView.update();
},
onFinish: () => {
updateWorkSessionActions();
if (selectedWorkFilter === 'agenda') {
closeOpenWorkSheets();
if (mobileStartDay.completePhase('agenda')) return;
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Agenda complete.';
return;
}
todayRecapView.finish(selectedWorkFilter);
},
});
function currentTodayProgressTarget() {
if (!workSession.checkpointed()) return null;
const item = workSession.target('continue');
if (!item || item.is_review || !['issue', 'pull'].includes(item.kind) ||
!item.repository || !Number.isInteger(item.number)) return null;
return {
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
number:item.number, label:item.key || item.repository + '#' + item.number,
title:item.title || '', item,
};
}
const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
voice:todayProgressVoice, mentions:todayProgressMentions,
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
moveOn:(target,until) => detailDefer.deferUntil(target.item,until),
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
const agendaSessionLauncher = createAgendaSessionLauncher({
selectAgenda: () => selectMobileQueue('agenda'),
discover: completeAgendaIssues,
isAgendaSelected: () => selectedWorkFilter === 'agenda',
hasMore: () => Boolean(workPagination.issue?.has_more),
hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
resume: () => workSession.resume(),
start: () => workSession.start(),
announce: announceWork,
});
function openAgendaSession() {
return agendaSessionLauncher.open();
}
let todayReadinessTrigger = null;
let todayReadinessBlockerFocus = null;
let searchPreviewReturnKind = null;
function inspectTodayDependencies(item) {
if (offlineWorkMode) {
const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim();
const detail = offlineWorkStore.loadDetail(login, item);
return Promise.resolve({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
});
}
const [owner, repo] = String(item.repository || '').split('/');
return api('api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/issues/' + encodeURIComponent(item.number) + '/detail').then(detail => ({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
}));
}
function suspendTodayReadiness() {
qs('#today-readiness-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
}
function closeTodayReadiness(navigate = true) {
suspendTodayReadiness();
if (navigate) taskOverlayHistory.close();
else {
todayReadiness.cancel();
todayReadinessBlockerFocus = null;
requestAnimationFrame(() => todayReadinessTrigger?.focus());
}
}
function renderTodayReadiness(state) {
if (!state?.target) return;
const unknown = state.status === 'unknown';
qs('#today-readiness-title').textContent = unknown ? 'Blocker status unavailable' : 'This item is blocked';
qs('#today-readiness-summary').textContent = unknown
? 'Stackchain could not confirm whether this issue is ready. Retry or choose an explicit override.'
: state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.');
qs('#today-readiness-item').innerHTML = '' + escapeHtml(state.target.title || 'Untitled issue') +
'
' + escapeHtml(state.target.repository + '#' + state.target.number) + '
';
const blockers = qs('#today-readiness-blockers');
blockers.innerHTML = state.dependencies.map((blocker, index) =>
''
).join('');
blockers.querySelectorAll('[data-today-blocker-index]').forEach(button => {
button.addEventListener('click', () => {
const blocker = state.dependencies[Number(button.dataset.todayBlockerIndex)];
todayReadinessBlockerFocus = blocker?.repository + '#' + blocker?.number;
todayReadiness.previewBlocker(blocker);
});
});
qs('#today-readiness-next').hidden = !state.nextReady;
if (state.nextReady) qs('#today-readiness-next').textContent = 'Start next ready · ' +
(state.nextReady.title || state.nextReady.key || 'work item');
qs('#today-readiness-retry').hidden = !unknown;
qs('#today-readiness-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => {
const blocker = state.dependencies.findIndex(candidate =>
candidate?.repository + '#' + candidate?.number === todayReadinessBlockerFocus
);
if (blocker >= 0) blockers.querySelector('[data-today-blocker-index="' + blocker + '"]')?.focus();
else (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus();
});
}
function performTodayTransition(action, item = null) {
qs('#today-readiness-sheet').hidden = true;
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
const transitioned = ({
start: () => workSession.start(item),
resume: () => workSession.resume(item),
continue: () => workSession.reopen(item),
next: () => workSession.next(item),
complete: () => workSession.complete(item),
})[action]?.();
if (!transitioned && action !== 'next' && action !== 'complete') {
qs('#my-work-action-status').textContent = 'Saved Today item is no longer available.';
updateWorkSessionActions();
}
}
const todayReadiness = createTodayReadiness({
inspect:inspectTodayDependencies,
onOpen:performTodayTransition,
onGate:state => {
todayReadinessTrigger = document.activeElement;
renderTodayReadiness(state);
taskOverlayHistory.open('today-readiness');
},
onPreview:blocker => {
searchPreviewReturnKind = 'today-readiness';
searchPreview.open({ ...blocker, kind:'issue' }).catch(() => {});
taskOverlayHistory.open('search-preview');
},
});
function runTodayTransition(action) {
selectTodayWork();
const target = workSession.target(action);
if (!target) {
performTodayTransition(action);
return Promise.resolve('empty');
}
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
return todayReadiness.run(action, workSession.items(), target);
}
function continueTodaySession() {
return runTodayTransition('continue');
}
function resumeTodaySession() {
return runTodayTransition('resume');
}
function startTodaySession() {
return runTodayTransition('start');
}
const createAndStart = createCreateAndStart({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
start: item => {
qs('[data-work-filter="today"]').click();
workSession.start(item);
},
announce: announceWork,
});
function renderCreateStartCapacity() {
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result);
return result;
}
qs('#create-issue-estimate').addEventListener('input', () => { renderCreateStartCapacity(); saveIssueCaptureDraft(); });
const queueToday = createQueueToday({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
});
const laterAndStart = createLaterAndStart({
todayWork,
todaySync,
laterWork,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
start: item => {
qs('[data-work-filter="today"]').click();
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
return todayReadiness.run('start', workSession.items(), item);
},
announce: announceWork,
});
const selectedSessionItem = kind => ({
issue: selectedIssue,
pull: selectedPull,
review: selectedReview,
update: selectedUpdate,
})[kind] || null;
const completeTodayItem = createTodayCompletion({
todayWork,
todaySync,
workSession,
refresh: () => refreshMyWorkView({ reconcileSession:false }),
warm: warmTodayOffline,
announce: announceWork,
advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete')),
});
todayLockScreen = createTodayLockScreen({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
serviceWorker:navigator.serviceWorker,
NotificationRef:window.Notification,
control:qs('#today-lock-screen'),
status:qs('#today-lock-screen-status'),
locationRef:window.location,
historyRef:window.history,
onAction:(action, expectedIdentity) => {
const state = timer.snapshot();
if (action === 'complete') {
selectTodayWork();
if (!state.identity || state.identity !== expectedIdentity) {
qs('#my-work-action-status').textContent =
'That lock-screen action is stale. The current Today item was not changed.';
return;
}
const item = todayMyWork.find(entry => todayWork.identity(entry) === expectedIdentity);
if (!item) {
qs('#my-work-action-status').textContent =
'That Today item is no longer available. Nothing was changed.';
return;
}
workSession.reopen(item);
completeTodayItem(item);
timerView.render();
return;
}
if (!state.identity || (action === 'pause' && !state.running) ||
(action === 'resume' && state.running)) return;
const changed = action === 'pause' ? timer.pause() : timer.resume();
if (changed === false) return;
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
if (item) workSession.reopen(item);
timerView.render();
},
});
await todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
await todayLockScreen.consumeLaunchAction();
async function completeOwnershipExitToday(item) {
if (!item || !todayWork.remove(item)) return null;
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refreshMyWorkView({ reconcileSession:false });
warmTodayOffline();
return await runTodayTransition('complete');
}
function setCommentNextVisibility(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
}
const issueCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await issueController.comment(item, body);
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const pullCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await pullController.comment(item, body);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const updateReplyReadNext = createUpdateReplyReadNext({
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
markRead: markNotificationRead,
queue: message => authoredOutbox.enqueueDurably(message),
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
canQueue: canQueueMessage,
accept: item => {
notificationReplier.saveDraft(item, '');
if (selectedUpdate === item) qs('#update-reply').value = '';
},
next: item => notificationReader.acceptReadAndNext(lastMyWork, item),
});
const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options),
});
function reviewingActiveTodayItem() {
return workSession.checkpointed();
}
function acceptClaimedIssue(confirmed) {
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
candidate.repository !== confirmed.repository || candidate.number !== confirmed.number
));
lastMyWork = buildMyWork(lastContextSnapshot);
return lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
}
const assignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: item => findWorkController.claim(item),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
queue: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
return queueToday(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#find-work'));
},
announce: message => {
qs('#find-work-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
const filedAssignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: item => findWorkController.claim(item),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
closeIssueSheet(false);
refreshMyWorkView();
return createAndStart.complete(claimed);
},
queue: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
const outcome = queueToday(claimed);
closeIssueSheet(false);
refreshMyWorkView();
openRoutedWork(claimed, qs('#my-work'));
return outcome;
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
closeIssueSheet(false);
refreshMyWorkView();
openRoutedWork(claimed, qs('#my-work'));
},
announce: message => {
qs('#issue-sheet-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
const batchFindWork = createBatchFindWork({
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
owner:()=>planningOwnerLogin,timeBudget:()=>{
const plan = todayWork.planning();
return {
capacity_minutes: plan.capacity_minutes,
planned_minutes: Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0),
};
},
claim: item => findWorkController.claim(item),
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
persistEstimate: (confirmed, minutes) => {
const plan = todayWork.planning();
plan.estimates[todayWork.identity(confirmed)] = minutes;
todayWork.replacePlanning(plan);
todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates);
todaySync.flush();
},
onProgress: progress => {
if (progress.status === 'full') {
qs('#find-work-status').textContent = 'Today has ' + progress.available +
' open slot' + (progress.available === 1 ? '' : 's') + '. Reduce the selection before assigning.';
} else if (progress.status === 'estimates-required') {
qs('#find-work-estimate-summary').textContent = 'Add an estimate for every issue.';
} else if (progress.status === 'over-budget') {
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(progress.over_minutes) +
' over Today’s remaining time. Reduce an estimate or selection.';
} else if (progress.status === 'running') {
qs('#find-work-status').textContent = 'Assigning and queueing ' + progress.processed +
' of ' + progress.selected + '…';
}
},
});
function findWorkEstimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-find-work-estimate]')).map(input =>
[input.dataset.findWorkEstimate, Number(input.value)]
));
}
function openFindWorkEstimateReview(items) {
const plan = todayWork.planning();
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0;
qs('#find-work-estimate-summary').textContent = estimatesRequired ?
formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
' remaining. Estimate selected work before assigning it.' :
items.length + ' selected. Confirm to assign and queue this batch in Today.';
qs('#find-work-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
return '' + escapeHtml(id) +
'' + escapeHtml(item.title || 'Untitled work') + '
' +
(estimatesRequired ? '
' : '') + '
';
}).join('');
if (estimatesRequired) qs('[data-find-work-estimate]')?.focus();
}
function formatCalendarDueDate(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
if (!match) return '';
return new Intl.DateTimeFormat(undefined).format(
new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
);
}
let planTodayTrigger = null;
let pendingProtectToday = null;
function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set';
const absolute = Math.abs(minutes);
const hours = Math.floor(absolute / 60);
const remainder = absolute % 60;
return [hours ? hours + 'h' : '', remainder ? remainder + 'm' : ''].filter(Boolean).join(' ') || '0m';
}
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
const title = escapeHtml(item.title || 'Untitled work');
const state = planToday.snapshot();
const estimate = state.estimates?.[id] || '';
const recommendation = state.recommendations?.[id];
const estimateControl = selected ? '' : '';
const recommendationControl = selected && recommendation ? '' : '';
const controls = selected ?
'' :
(item.kind === 'issue' ?
'' :
'');
return '' + key + '' + title + '' + estimateControl + recommendationControl + '
' + controls + '';
}
function resetPlanTodayConfirmation() {
const save = qs('#save-today-plan');
const start = qs('#save-and-start-today');
save.dataset.confirmOverCapacity = '';
start.dataset.confirmOverCapacity = '';
save.textContent = 'Save plan';
start.textContent = 'Save & start';
}
function renderPlanToday() {
resetPlanTodayConfirmation();
const state = planToday.snapshot();
const capacityText = state.capacity_minutes === null ? 'Set available time to check fit' :
(state.over_capacity ? formatPlanMinutes(-state.remaining_minutes) + ' over capacity' :
formatPlanMinutes(state.remaining_minutes) + ' free');
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected · Planned ' +
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
qs('#plan-today-available').value = state.capacity_minutes || '';
const build = state.build;
qs('#plan-today-build-status').textContent = build ?
('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') +
(build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') +
(build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : '';
const estimateSection = qs('#plan-today-estimates');
estimateSection.hidden = !build?.needs_estimate.length;
qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
const item = planToday.item(id);
const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
return '';
}).join('');
qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
'' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
' · ' + escapeHtml(entry.reason) + '
'
).join('');
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : 'No work selected yet.
';
const candidates = planToday.candidates();
qs('#plan-today-candidates').innerHTML = candidates.length ? candidates.map(item =>
planTodayItemMarkup(item, false)
).join('') : 'All available work is already selected.
';
document.querySelectorAll('[data-plan-add]').forEach(button => button.addEventListener('click', () => {
const result = planToday.toggle(planToday.item(button.dataset.planAdd));
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-preview]').forEach(button => button.addEventListener('click', () => {
const item = planToday.item(button.dataset.planPreview);
if (!canPreviewPlanItem(item)) {
qs('#plan-today-error').textContent = 'Preview unavailable offline. Reconnect or add the item without previewing it.';
return;
}
qs('#plan-today-error').textContent = '';
if (planTodayPreview.open(item, button)) taskOverlayHistory.open('plan-today-preview');
}));
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
planToday.toggle(planToday.item(button.dataset.planRemove));
qs('#plan-today-error').textContent = '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-move]').forEach(button => button.addEventListener('click', () => {
planToday.move(button.dataset.planId, button.dataset.planMove);
renderPlanToday();
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
}));
document.querySelectorAll('[data-plan-estimate]').forEach(input => input.addEventListener('change', () => {
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
const id = input.dataset.planMissingEstimate;
if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
input.focus();
return;
}
qs('#plan-today-error').textContent = '';
renderPlanToday();
document.querySelector('[data-plan-missing-estimate]')?.focus();
}));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
document.querySelector('[data-plan-estimate="' + CSS.escape(button.dataset.planRecommendation) + '"]')?.focus();
}));
}
function closePlanToday(navigate = true) {
planTodayReadiness.cancel();
qs('#build-today-plan').disabled = false;
qs('#plan-today-build-status').textContent = '';
if (navigate) {
taskOverlayHistory.close();
return;
}
planToday.cancel();
planningTomorrow = false;
wc.close();
weekFlow.clear();
qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
qs('#week-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') {
if (!qs('#mobile-queue-sheet').open) qs('#mobile-queue-sheet').showModal();
}
planTodayTrigger?.focus();
}
function saveTodayPlan(plan) {
const capacityAware = !Array.isArray(plan);
const ids = capacityAware ? plan.ids : plan;
const previous = todayWork.read();
if (rolloverReviewPlan) {
const operation = todayRollover.operation({
operation_id: 'pending', base_revision: rolloverReviewPlan.revision,
selected_ids: ids, capacity_minutes: plan.capacity_minutes, estimates: plan.estimates,
});
if (!todaySync.enqueueRollover(operation)) return false;
rolloverReviewPlan = null;
qs('#plan-today').textContent = 'Plan Today';
qs('#plan-today-title').textContent = 'Plan Today';
} else {
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
}
if (!todayWork.replace(ids)) return false;
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
todayRecapView.completeReplan();
refreshMyWorkView();
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order and available time.' : 'Today plan cleared.';
return true;
}
function saveTomorrowPlan(plan) {
const normalized = Array.isArray(plan) ?
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
const staged = tomorrowPlan.stage(normalized);
if (!staged) {
qs('#my-work-action-status').textContent =
'Tomorrow could not be saved on this phone. Free browser storage and retry.';
return false;
}
renderTomorrowQueueSummary(staged);
qs('#my-work-action-status').textContent = 'Tomorrow saved on this phone · sync pending.';
syncPendingTomorrow();
return true;
}
const planToday = createPlanToday({
identity: item => todayWork.identity(item),
limit: todayWork.limit,
save: plan => weekFlow.active() ? weekFlow.save(plan) :
(planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)),
start: () => {
qs('[data-work-filter="today"]').click();
startTodaySession();
},
});
const planTodayReadiness = createPlanTodayReadiness({
concurrency:3,
identity:item => todayWork.identity(item),
inspect:async item => {
if (item.kind !== 'issue') return { status:'ready' };
const detail = await inspectTodayDependencies(item);
if (!detail.available) return { status:'unverified' };
if (detail.dependencies.length) return {
status:'blocked', reason:detail.dependencies.length + ' open ' +
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
};
return { status:'ready' };
},
});
async function buildTodayPlan() {
const button = qs('#build-today-plan');
const state = planToday.snapshot();
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
button.disabled = true;
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
const checks = await planTodayReadiness.run(candidates);
if (!checks) return;
planToday.buildRecommendation(checks);
button.disabled = false;
renderPlanToday();
}
function canPreviewPlanItem(item) {
if (!item || !offlineWorkMode) return Boolean(item);
const login = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
return Boolean(offlineWorkStore.loadDetail(login, item));
}
function openPlanPreviewDetail(item, trigger) {
qs('#plan-today-sheet').hidden = true;
qs('#plan-preview-actions').hidden = false;
if (item.kind === 'issue') renderPlanIssueDependencies(null, true);
if (offlineWorkMode) {
openRoutedWork(item, trigger);
} else if (item.kind === 'update' && item.has_update) {
updateTrigger = trigger;
notificationReader.open(item, lastMyWork);
} else if (item.is_review || item.kind === 'review') {
reviewTrigger = trigger;
openReviewSheet(item, trigger);
} else if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger);
} else if (item.kind === 'pull') {
pullTrigger = trigger;
openPullSheet(item, trigger);
}
}
const planTodayPreview = createPlanTodayPreview({
planner: planToday,
identity: item => todayWork.identity(item),
getScroll: () => qs('.plan-today-panel').scrollTop,
setScroll: value => requestAnimationFrame(() => { qs('.plan-today-panel').scrollTop = value; }),
onOpen: openPlanPreviewDetail,
onClose: (_item, trigger) => {
closeOpenWorkSheets();
qs('#plan-preview-actions').hidden = true;
qs('#issue-blockers').hidden = true;
qs('#issue-blocker-list').textContent = '';
qs('#add-plan-preview').disabled = false;
qs('#add-plan-preview').textContent = 'Add to Today & back';
qs('#plan-today-sheet').hidden = false;
renderPlanToday();
requestAnimationFrame(() => trigger?.focus());
},
});
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail);
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
const status = qs('#issue-blocker-status');
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
qs('#manage-issue-blockers').hidden = !assignedIssue;
qs('#start-unblocked-issue').hidden = true;
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
if (assignedIssue) {
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
list.innerHTML = dependencies.map((blocker, index) =>
''
).join('');
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
button.addEventListener('click', () => {
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
mutateIssueBlocker(blocker, true, button);
}
});
});
status.textContent = available ? (dependencies.length ?
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
'No unresolved blockers. Add one without leaving this issue.') :
'Blocker status unavailable. Reload before changing relationships.';
qs('#manage-issue-blockers').disabled = !available;
return;
}
addButton.disabled = loading;
addButton.dataset.planOverride = '';
if (loading) {
status.textContent = 'Checking unresolved blockers…';
addButton.textContent = 'Checking blockers…';
return;
}
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
planTodayPreview.setDependencies({ available, dependencies });
if (!available) {
status.textContent = 'Blocker status unavailable. Adding requires an explicit override.';
addButton.textContent = 'Add without blocker status anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
if (dependencies.length) {
list.innerHTML = dependencies.map(blocker =>
'' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'State: ' + escapeHtml(blocker.state || 'open') + ''
).join('');
status.textContent = dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.');
addButton.textContent = 'Add blocked item anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
panel.hidden = true;
status.textContent = '';
addButton.textContent = 'Add to Today & back';
}
async function mutateIssueBlocker(blocker, remove, button) {
if (!selectedIssue) return;
const item = selectedIssue;
button.disabled = true;
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
try {
if (selectedIssueOffline) {
await queueOfflineIssueBlocker(item, blocker, !remove);
if (selectedIssue !== item) return;
button.textContent = 'Queued';
qs('#issue-blocker-status').textContent = remove ?
'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
return { queued:true };
}
const result = remove ?
await issueController.updateBlocker(selectedIssue, blocker, true) :
await issueController.updateBlocker(selectedIssue, blocker, false);
if (selectedIssue !== item) return;
selectedIssueDetail = { ...selectedIssueDetail, ...result };
renderPlanIssueDependencies(selectedIssueDetail);
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
qs('#start-unblocked-issue').hidden = false;
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
button.disabled = false;
button.focus();
}
}
function renderIssueBlockerCandidates(items) {
issueBlockerCandidates = items;
const results = qs('#issue-blocker-results');
results.innerHTML = items.map((result, index) =>
''
).join('');
results.querySelectorAll('[data-blocker-result]').forEach(button => {
button.addEventListener('click', () => {
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
candidate.repository === blocker.repository && candidate.number === blocker.number
)) {
qs('#issue-blocker-manager').hidden = true;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
}
});
});
});
}
qs('#manage-issue-blockers').addEventListener('click', () => {
qs('#issue-blocker-manager').hidden = false;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
qs('#issue-blocker-search').focus();
});
qs('#cancel-issue-blocker').addEventListener('click', () => {
qs('#issue-blocker-manager').hidden = true;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
qs('#manage-issue-blockers').focus();
});
qs('#issue-blocker-search').addEventListener('keydown', event => {
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
});
qs('#issue-blocker-search').addEventListener('input', event => {
clearTimeout(issueBlockerSearchTimer);
const query = event.target.value.trim();
if (query.length < 2) {
renderIssueBlockerCandidates([]);
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
return;
}
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
issueBlockerSearchTimer = setTimeout(async () => {
try {
const candidates = selectedIssueOffline ? workSession.items() :
(await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
const existing = selectedIssueDetail?.dependencies || [];
const normalizedQuery = query.toLowerCase();
const items = (candidates || []).filter(result =>
result.kind === 'issue' && result.state === 'open' &&
(!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
.toLowerCase().includes(normalizedQuery)) &&
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
);
renderIssueBlockerCandidates(items);
qs('#issue-blocker-search-status').textContent = items.length ?
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
} catch (error) {
renderIssueBlockerCandidates([]);
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
}
}, 250);
});
qs('#start-unblocked-issue').addEventListener('click', () => {
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
});
let addPlanPreviewOnReturn = false;
let addPlanPreviewOverride = false;
qs('#back-to-plan').addEventListener('click', () => {
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
taskOverlayHistory.close();
});
qs('#add-plan-preview').addEventListener('click', () => {
addPlanPreviewOnReturn = true;
addPlanPreviewOverride = qs('#add-plan-preview').dataset.planOverride === 'true';
taskOverlayHistory.close();
});
let pendingPlanActualMinutes = null;
function tomorrowConflictPlanMarkup(value) {
const ids = value?.ids || [];
const estimates = value?.estimates || {};
const estimated = ids.reduce((total, id) => total + (Number(estimates[id]) || 0), 0);
const capacity = Number(value?.capacity_minutes) || 0;
const summary = ids.length ? `${ids.length} planned` +
(estimated && capacity ? ` · ${estimated} of ${capacity} min` : '') : 'Nothing planned';
const available = [...todayMyWork, ...activeMyWork];
const items = ids.map(id => {
const item = available.find(candidate => todayWork.identity(candidate) === id);
const label = item?.title || item?.key || id;
const estimate = Number(estimates[id]) || 0;
return `${escapeHtml(label)}${estimate ? ` · ${estimate} min` : ''}`;
}).join('');
return `${summary}
` +
(items ? `${items}
` : 'No work selected.
');
}
function showTomorrowConflict(conflict) {
qs('#plan-today-title').textContent = 'Resolve Tomorrow conflict';
qs('#tomorrow-conflict-phone-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.local);
qs('#tomorrow-conflict-server-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.remote);
qs('#tomorrow-conflict-status').textContent = '';
qs('#tomorrow-conflict-review').hidden = false;
qs('#plan-today-sheet').classList.add('tomorrow-conflict-mode');
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
const keep = qs('#keep-phone-tomorrow');
keep.focus();
requestAnimationFrame(() => keep.focus());
}
function showWeekConflict(conflict) {
qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
const focus=weekFlow.renderConflict(conflict);
qs('#week-conflict-status').textContent='';
qs('#week-conflict-review').hidden=false;
qs('#plan-today-sheet').classList.add('week-conflict-mode');
qs('#plan-today-sheet').hidden=false;
document.body.classList.add('task-overlay-open');
focus.focus();requestAnimationFrame(()=>focus.focus());
}
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
if (!planningOwnerLogin && !weekFlow.active()) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
if (trigger) planTodayTrigger = trigger;
const weekCopy=weekFlow.copy();
qs('#plan-today-title').textContent = weekCopy ? weekCopy.title : (planningTomorrow ? 'Plan Tomorrow' :
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today')));
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
if (navigate) {
taskOverlayHistory.open('plan-today');
return;
}
const conflict = planningTomorrow ? tomorrowPlan.conflict() : null;
if (conflict) {
showTomorrowConflict(conflict);
return;
}
const weekConflict=weekFlow.active()?weekPlan.conflict():null;
if(weekConflict){showWeekConflict(weekConflict);return;}
qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
qs('#week-conflict-review').hidden=true;
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
pendingPlanActualMinutes = null;
const protectProposal = pendingProtectToday;
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
const weekDay = weekFlow.day();
const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) =>
items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index
);
const selected = (planningTomorrow || weekCopy) ? availablePlanningItems.filter(item =>
(weekDay || tomorrow).ids.includes(todayWork.identity(item))
) : (protectProposal?.selected || todayMyWork);
planToday.open(selected, activeMyWork, weekDay || (planningTomorrow ? tomorrow : todayWork.planning()), recommendations);
qs('#today-plan-heading').textContent = weekCopy ? weekCopy.heading : (planningTomorrow ? 'Tomorrow, in order' : 'Today, in order');
qs('.plan-today-available').firstChild.textContent = weekCopy ? weekCopy.available : (planningTomorrow ? 'Available this day' : 'Available today');
qs('#build-today-plan').textContent = weekCopy ? weekCopy.build : (planningTomorrow ? 'Build my Tomorrow' : 'Build my Today');
qs('#save-and-start-today').hidden = planningTomorrow || Boolean(weekCopy);
pendingProtectToday = null;
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
renderPlanToday();
weekFlow.renderDates();
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');
qs('#cancel-plan-today').focus();
}
qs('#protect-today').addEventListener('click', event => {
const proposal = protectToday.propose({
agenda:agendaMyWork(activeMyWork), today:todayMyWork,
identity:item => todayWork.identity(item), limit:todayWork.limit,
});
if (!proposal.protected.length) {
qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.';
return;
}
pendingProtectToday = proposal;
openPlanToday(event.currentTarget);
});
qs('#discard-recap-replan').addEventListener('click', () => {
todayRecapView.discardReplan();
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
qs('#discard-recap-replan').hidden = true;
qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.';
renderPlanToday();
});
const detailDefer = createDetailDefer({
laterWork,
session: workSession,
continueSession: item => completeTodayItem(item, {
successMessage: 'Deferred to Later. Next Today item opened.',
failureMessage: 'Could not remove this item from Today.',
}),
close: () => workRoute.close(),
refresh: refreshMyWorkView,
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
announce: announceWork,
formatTime: fmt,
});
const laterPickerElement = qs('#later-picker');
const laterPickerInput = qs('#later-picker-time');
let searchDefer = null;
const laterPicker = createLaterPicker({
history: window.history,
eventTarget: window,
onState: state => {
qs('#later-picker-error').textContent = state.message || '';
if (state.open) {
laterPickerInput.value = state.value || laterPickerInput.value;
qs('#later-picker-timezone').textContent = 'Times use ' +
(Intl.DateTimeFormat().resolvedOptions().timeZone || 'your device timezone') + '.';
laterPickerElement.hidden = false;
if (!laterPickerElement.open) laterPickerElement.showModal();
laterPickerInput.focus();
} else {
if (laterPickerElement.open) laterPickerElement.close();
laterPickerElement.hidden = true;
}
},
onConfirm: (item, until, context) => {
if (context === 'search') {
return () => searchDefer.run(item, until).then(outcome => {
if (outcome === 'deferred') return next();
}).catch(error => {
qs('#search-preview-status').textContent = error.message + ' Retry.';
});
}
if (context === 'detail') {
const inSession = workSession.active();
const deferred = detailDefer.deferUntil(item, until, {
closeSheet:false,
restoreFocus:false,
});
if (!deferred) return false;
return inSession ? true : () => workRoute.close();
}
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return false;
refreshMyWorkView();
return true;
},
});
laterPicker.start();
qs('#later-picker-form').addEventListener('submit', event => {
event.preventDefault();
laterPicker.submit(laterPickerInput.value);
});
qs('#cancel-later-picker').addEventListener('click', () => laterPicker.close());
laterPickerElement.addEventListener('cancel', event => {
event.preventDefault();
laterPicker.close();
});
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
detailDefer.defer(item, button.dataset.detailDeferPreset);
});
});
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
laterPicker.open(item, button, 'detail');
});
});
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
button.addEventListener('click', () => {
const chooser = button.closest('.detail-defer');
chooser.open = false;
chooser.querySelector('summary')?.focus();
});
});
function updatePlanningAvailability() {
document.querySelectorAll('[data-detail-defer-preset], [data-detail-defer-custom]').forEach(button => {
button.disabled = !planningOwnerLogin;
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
});
}
function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [
'#edit-issue-content', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
] : createPullSheet.ownershipSelectors().concat([
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
]);
selectors.forEach(selector => {
const control = qs(selector);
if (control) control.disabled = true;
});
if (kind === 'issue') {
qs('#issue-planning').inert = true;
qs('#issue-handoff').inert = true;
} else {
qs('#pull-review').inert = true;
qs('#pull-ownership').inert = true;
}
}
function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#acknowledge-update-next').disabled = offline;
qs('#mute-update-next').disabled = offline;
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;
}
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
const merge = (older, latest) => {
const byId = new Map((older || []).map(item => [item.id, item]));
(latest || []).forEach(item => {
const current = byId.get(item.id) || {};
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
byId.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
return Array.from(byId.values());
};
data.issues = merge(lastContextSnapshot.issues, data.issues);
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
}
lastContextSnapshot = data;
if (data.work_pagination) workPager.reset(data.work_pagination);
if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data);
openStagedSharedContent();
qs('#context').innerHTML = 'User
' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '
' +
'
Repos
' + (data.repos?.length || 0) + '
' +
'
Issues
' + (data.issues?.length || 0) + '
' +
'
PRs
' + (data.pull_requests?.length || 0) + '
';
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
const issuesBox = qs('#issues-content');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '').join('') : 'No open issues.
');
const prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '').join('') : 'No PRs.
');
paintDeltas(data.deltas || []);
qs('#layout-hint').innerHTML = 'Active view
' + escapeHtml(data.view || 'dashboard') + '
Deltas
' + (data.deltas||[]).length + '
';
renderRepoMix(qs('#repo-mix'), data);
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
setClock();
}
async function handleContextError(e) {
console.error('context failed', e);
liveMode = false;
activeFlushLogin = '';
if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;
const timeoutStatus = e.name === 'TimeoutError' ?
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
if (!hasContextSnapshot) {
qs('#context').innerHTML = 'Context unavailable.
';
qs('#view-hint').textContent = 'Active view unavailable.';
qs('#issues-content').innerHTML = 'Work items unavailable.
';
qs('#prs-content').innerHTML = 'Work items unavailable.
';
paintDeltas([]);
}
markMyWorkStale();
}
function paintMyWork(data) {
lastMyWork = buildMyWork(data);
refreshMyWorkView();
void syncCompletedFiledReviews();
}
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
title:unfiledDraftDisplayTitle(item),
preview:[item.body, item.hasAttachment ? 'Photo evidence attached' : ''].filter(Boolean).join(' · '),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
}));
return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0)
);
}
async function refreshPhotoDraftInbox() {
try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false }); }
catch (_error) { /* Local photo inventory must not break My Work. */ }
}
function refreshMyWorkView({ reconcileSession = true, refreshFirstTask = true } = {}) {
lastDrafts = listDrafts();
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
const partitioned = laterWork.partition(actionableMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
activeMyWork = partitioned.active;
laterMyWork = partitioned.later;
const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot &&
!lastContextSnapshot?.error &&
!Object.values(workPagination).some(page => page?.has_more);
todayMyWork = todayWork.reconcile(actionableMyWork, {
pruneMissing: authoritativeMyWorkRefresh,
onPrune: retiredIds => {
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
if (queued) todaySync.flush();
return queued;
},
});
const counts = countMyWork(activeMyWork);
const agendaItems = agendaMyWork(activeMyWork);
counts.agenda = agendaItems.length;
counts.today = todayMyWork.length;
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
counts.gate = queueCounts.gate;
counts.gateUnavailable = queueCounts.gateUnavailable;
preparationItems = {
delivery:draftInbox.partition(lastDrafts).deliveries,
gate:preparationItems.gate || [],
agenda:agendaMyWork(activeMyWork),
attention:activeMyWork.filter(item => item.needs_attention),
update:activeMyWork.filter(item => item.has_update),
filed:activeMyWork.filter(item => item.is_filed),
};
if (!launchFilterResolved) {
selectedWorkFilter = mobileLaunch.chooseFilter({
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
agenda: counts.agenda,
});
launchFilterResolved = true;
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item.dataset.workFilter === selectedWorkFilter))
);
}
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
queueCounts = counts;
mobileQueueLauncher.renderPresentation();
mobileStartDay.reconcile({
authoritative:authoritativeMyWorkRefresh,
authoritativePhases:['delivery'],
});
mobileStartDay.render();
mobileTaskDock.updateQueues(counts);
if (refreshFirstTask) mobileFirstTask.refresh();
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
' (' + (counts[selectedWorkFilter] || 0) + ')';
const milestoneSelect = qs('#work-milestone-filter');
const lanes = milestoneLanes(lastMyWork);
milestoneSelect.innerHTML = '' +
'' + lanes.map(lane =>
''
).join('');
milestoneSelect.value = selectedWorkMilestone;
if (milestoneSelect.value !== selectedWorkMilestone) {
const retained = document.createElement('option');
retained.value = selectedWorkMilestone;
retained.textContent = 'Selected milestone';
milestoneSelect.append(retained);
milestoneSelect.value = selectedWorkMilestone;
}
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
if (selectedWorkFilter === 'agenda' && workPagination.issue?.has_more && !agendaChecking) {
completeAgendaIssues();
}
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
}
function activeWorkStreams() {
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'agenda') return ['issue'];
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'filed') return ['filed'];
if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) {
return [selectedWorkFilter];
}
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored'];
return [];
}
async function completeAgendaIssues() {
if (selectedWorkFilter !== 'agenda') return false;
if (!workPagination.issue?.has_more) return true;
if (!lastContextSnapshot) return false;
agendaChecking = true;
qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…';
renderMyWork();
const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []);
agendaChecking = false;
if (selectedWorkFilter !== 'agenda') return false;
qs('#my-work-action-status').textContent = complete ?
'All assigned deadlines checked.' :
'Agenda check paused. Retry to check older assigned deadlines.';
renderMyWork();
return complete && !workPagination.issue?.has_more;
}
function renderDrafts() {
const list = qs('#my-work-list');
const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
const deliveryCenter = draftInbox.partition(displayedDrafts);
const renderDraftCard = item => {
const index = lastDrafts.indexOf(item);
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue';
const reviewOutbox = item.outbox_kind === 'pull-review';
const closureOutbox = item.outbox_kind === 'issue-close';
const checklistConflict = item.checklist_conflict === true;
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ?
'' +
'' :
item.continuation ?
'' +
'' :
item.kind === 'issue-outbox' ?
'' +
'' +
'' :
reviewOutbox && item.status === 'authorization' ?
'' +
'' +
'' :
reviewOutbox && item.status === 'attention' ?
'' +
'' +
'' :
item.kind === 'authored-outbox' && checklistConflict ?
'' +
'' :
item.kind === 'authored-outbox' && closureOutbox ?
'' +
'' +
'' :
item.kind === 'authored-outbox' && !reviewOutbox ?
'' +
'' +
'' :
reviewOutbox ?
'' +
'' :
'' +
'';
const state = isOutbox ?
'' + draftInbox.deliveryLabel(item) + '' +
(item.ownership ? '' + escapeHtml(item.ownership) + '
' : '') : '';
const attempt = item.last_attempt_error ? 'Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '' : '';
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '' +
'' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '' +
'' + escapeHtml(item.title) + '' +
'' + escapeHtml(photoDraftInbox.description(item)) + '' + state + attempt +
'Saved ' + escapeHtml(fmt(item.updated_at)) + '' +
'' + outboxActions + '
';
};
const deliverySummary = '' +
'Delivery center
' +
'
Waiting ' + deliveryCenter.counts.waiting + ' · ' +
'Sending ' + deliveryCenter.counts.sending + ' · ' +
'Needs attention ' + deliveryCenter.counts.attention + ' · ' +
'Authorize ' + deliveryCenter.counts.authorization + '
' +
'';
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
'No queued deliveries.
';
const draftCards = deliveryCenter.drafts.length ? deliveryCenter.drafts.map(renderDraftCard).join('') :
'No unfinished drafts.
';
list.innerHTML = deliverySummary + '' +
'Queued deliveries
' + deliveryCards + '' +
'Unfinished drafts
' + draftCards + '';
updateQueueFinder(displayedDrafts.length, lastDrafts.length, false);
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
const button = event.currentTarget;
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Retrying waiting deliveries…';
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
applyOutboxResult(issueResult);
applyAuthoredOutboxResult(authoredResult);
qs('#my-work-action-status').textContent = 'Waiting deliveries retried; attention stayed queued.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
await dFS.open(item.capture_id, item.ready ? button : null);
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
}
else if (item.route) workRoute.open(item.route);
};
});
list.querySelectorAll('.draft-continue').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const completion = issueOutbox.pendingCompletions(activeFlushLogin)
.find(candidate => candidate.id === item?.outbox_id);
if (completion) applyOutboxResult({
confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(),
});
});
});
list.querySelectorAll('.draft-edit').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!queued) return;
button.disabled = true;
try {
const hydrated = await issueOutbox.hydrateForEdit(queued.id);
if (!hydrated) return;
editingOutboxId = hydrated.id;
issueCapture.saveDraft(hydrated);
openCreateIssueSheet();
if (hydrated.attachments || hydrated.attachment) {
createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment);
}
else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
} catch (error) {
qs('#my-work-action-status').textContent = String(error?.message ||
'The saved screenshot could not be loaded. Retry before editing this issue.');
} finally {
button.disabled = false;
}
});
});
list.querySelectorAll('.draft-send').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
});
});
list.querySelectorAll('.draft-review-checklist').forEach(button => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Loading the latest checklist for review…';
try {
const latest = await issueController.load({
repository:item.repository, number:item.number,
});
const preview = mergeChecklistConflict({
baseBody:queued.baseBody, localBody:queued.body, remoteBody:latest.body,
});
if (preview.conflicts.length) {
qs('#my-work-action-status').textContent = 'Checklist changes for ' +
preview.conflicts.map(conflict => conflict.label).join(', ') +
' could not be matched safely. Open the issue to compare renamed or deleted tasks.';
return;
}
if (!window.confirm('Apply ' + preview.changes.length + ' checklist change' +
(preview.changes.length === 1 ? '' : 's') + ' to the latest issue? Remote prose and other tasks will stay unchanged.')) return;
const result = await authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin);
if (result.conflicts.length) return;
const delivery = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(delivery);
qs('#my-work-action-status').textContent = delivery.confirmed?.length ?
'Checklist changes applied to the latest issue.' :
'The issue changed again. Review the checklist conflict against the new version.';
} catch (error) {
qs('#my-work-action-status').textContent = String(error?.message || 'Checklist review could not be loaded. Retry when online.');
} finally {
button.disabled = false;
}
};
});
list.querySelectorAll('.draft-authorize').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id || !activeFlushLogin) return;
button.disabled = true;
const reviewAuthorization = item.outbox_kind === 'pull-review';
qs('#my-work-action-status').textContent = reviewAuthorization ?
'Fresh authorization required for this exact review decision and head.' :
'Fresh authorization required for this exact issue.';
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(result);
qs('#my-work-action-status').textContent = result.confirmed?.length ?
(reviewAuthorization ? 'Review submitted and queued intent cleared.' :
'Issue closed and queued intent cleared.') :
(reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
'Issue closure was not confirmed. The queued intent is still safe.');
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.copy_text) return;
await navigator.clipboard.writeText(item.copy_text);
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
};
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') {
await unfiledCaptures.discard(item.capture_id);
}
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item?.kind === 'photo-reply') await photoDraftInbox.discard(item);
else if (item) draftInbox.discard(item.id);
lastDrafts = listDrafts();
const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length;
renderDrafts();
qs('#my-work-action-status').textContent = 'Draft discarded.';
});
});
mobileDeliveryRecovery.render();
}
function updateWorkPaginationControls() {
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests', authored: 'pull requests' };
const streams = activeWorkStreams();
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
const summaries = streams.flatMap(stream => {
const page = workPagination[stream];
return page && page.total ?
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
});
qs('#work-page-status').textContent = summaries.join(' · ');
qs('#load-more-work').hidden = incomplete.length === 0;
qs('#load-more-work').textContent = incomplete.length ?
'Load older ' + labels[incomplete[0]] : 'Load older work';
}
function renderMyWork() {
lastDrafts = listDrafts();
const draftCount = qs('[data-work-count="draft"]');
if (draftCount) draftCount.textContent = lastDrafts.length;
if (selectedWorkFilter === 'draft') {
renderDrafts();
qs('#bulk-mark-read-bar').hidden = true;
qs('#load-more-notifications').hidden = true;
qs('#filed-history-tabs').hidden = true;
return;
}
const filedItems = filedHistoryTabs.render(selectedFiledView, selectedWorkFilter === 'filed');
const queueItems = selectedWorkFilter === 'filed' ? filedItems : selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
agendaMyWork(activeMyWork) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const overdue = queueItems.filter(item => item.agenda_group === 'Overdue');
const urgentAgenda = selectedWorkFilter === 'agenda' ? queueItems.filter(item =>
item.agenda_group === 'Overdue' || item.agenda_group === 'Today') : [];
const protectPanel = qs('#protect-today-panel');
protectPanel.hidden = selectedWorkFilter !== 'agenda' || urgentAgenda.length === 0;
qs('#protect-today-status').textContent = urgentAgenda.length ?
urgentAgenda.length + ' urgent ' + (urgentAgenda.length === 1 ? 'deadline is' : 'deadlines are') + ' ready to reconcile with Today.' : '';
const replanPanel = qs('#agenda-replan');
replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0;
if (!agendaReplan?.snapshot().active) {
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
qs('#agenda-replan-controls').hidden = true;
}
const agendaExport = qs('#agenda-export');
const agendaExportButton = qs('#open-agenda-export');
agendaExport.hidden = selectedWorkFilter !== 'agenda';
agendaExportButton.disabled = queueItems.length === 0;
qs('#agenda-export-empty').textContent = queueItems.length ?
'Review ' + queueItems.length + (queueItems.length === 1 ? ' deadline' : ' deadlines') +
' before sharing a calendar snapshot.' :
'No deadlines are available to export from this Agenda.';
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
const visible = findQueueItems(queueItems, queueFindQuery);
const emptyWorkStart = qs('#empty-work-start');
const showEmptyStart = selectedWorkFilter === 'all' && !queueFindQuery &&
!queueItems.length && !incomplete && hasContextSnapshot;
emptyWorkStart.hidden = !showEmptyStart;
updateQueueFinder(visible.length, queueItems.length, incomplete);
const selection = notificationSelection.snapshot();
const selectedIds = new Set(selection.ids);
const workSelectionState = workSelection.snapshot();
const selectedWorkIds = new Set(workSelectionState.ids);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const routeItem = routedWorkItem(item);
const routeHref = createWorkRoute.serialize(routeItem);
const contents =
'' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '' +
'' + escapeHtml(item.title) + '' +
(item.agenda_group ? '' + escapeHtml(item.agenda_group) + '' : '') +
'' + escapeHtml(item.reason) + '' +
(item.milestone?.title ? ' ' + escapeHtml(item.milestone.title) + '' : '') +
(item.due_label ? ' ' + escapeHtml(item.due_label) + '' : '') +
(item.has_update ? ' Unread update' : '') +
(item.deferred_until ? 'Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '' : '') +
(item.updated_at ? ' · Updated ' + escapeHtml(fmt(item.updated_at)) + '' : '');
const selectable = selectedWorkFilter === 'update' && selection.active &&
item.has_update && Number.isInteger(item.notification_id);
const workSelectable = workSelectionState.active;
const workId = workSelection.identity(item);
const selector = workSelectable ?
'' : (selectable ?
'' : '');
const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') +
((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
'' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'Read update' : '';
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
const laterActions = selectedWorkFilter === 'later' ?
'' :
'';
const alreadyToday = todayWork.contains(item);
const todayPosition = todayWork.position(item);
const todayActions = selectedWorkFilter === 'today' ?
'' :
'';
const planningActions = selectedWorkFilter === 'later' ? laterActions :
'Plan or defer
' + todayActions + laterActions + '
';
if (item.is_review) {
return '' + selector + '' + contents + '' + readUpdate + markRead + planningActions + '';
}
if (item.kind === 'issue') {
return '' + selector + '' + contents + '' + readUpdate + markRead + planningActions + '';
}
if (item.kind === 'pull') {
return '' + selector + '' + contents + '' + readUpdate + markRead + planningActions + '';
}
return '' + selector + '' + contents + '' + markRead + planningActions + '';
}).join('') : (showEmptyStart ? '' : '' +
(selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ?
(agendaChecking ? 'Checking all assigned deadlines…' :
'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ?
'More work is available. Load the next page.' : (selectedWorkFilter === 'filed' && selectedFiledView === 'reviewed' ?
'No reviewed outcomes yet' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.'))) + '
');
cardPlanning.wire();
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
input.addEventListener('change', () => {
const notificationId = Number(input.dataset.selectNotificationId);
const result = input.checked ? notificationSelection.select(notificationId) :
notificationSelection.toggle(notificationId);
if (result === 'limit') {
input.checked = false;
qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
}
});
});
document.querySelectorAll('[data-select-work-id]').forEach(input => {
input.addEventListener('change', () => {
const item = lastMyWork[Number(input.dataset.workIndex)];
const result = input.checked ? workSelection.select(item) : workSelection.toggle(item);
if (result === 'limit') {
input.checked = false;
qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.';
}
});
});
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
document.querySelectorAll('[data-issue-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.issueIndex)], button); });
});
document.querySelectorAll('[data-pull-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.pullIndex)], button); });
});
document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', event => {
event.preventDefault();
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
openRoutedWork(item, button);
});
});
document.querySelectorAll('[data-notification-id]').forEach(button => {
button.addEventListener('click', async () => {
const notificationId = Number(button.dataset.notificationId);
button.disabled = true;
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
if (acknowledged) {
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
} else {
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
}
});
});
document.querySelectorAll('[data-later-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
const until = laterWork.presetUntil(button.dataset.laterPreset);
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return;
refreshMyWorkView();
});
});
document.querySelectorAll('[data-later-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
laterPicker.open(item, button, 'card');
});
});
document.querySelectorAll('[data-later-restore]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item || !laterWork.restore(item)) return;
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-later-start]').forEach(button => {
button.addEventListener('click', async () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
button.disabled = true;
await laterAndStart.start(item);
if (button.isConnected) button.disabled = false;
});
});
document.querySelectorAll('[data-today-add]').forEach(button => {
button.addEventListener('click', () => {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
qs('#my-work-action-status').textContent = result === 'full' ?
'Today is limited to 5 items. Remove one before adding more.' :
(result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView();
if (result === 'added') {
const item = lastMyWork[Number(button.dataset.workIndex)];
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.remove(item)) {
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
}
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.move(item, button.dataset.todayMove)) {
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
todaySync.flush();
}
refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
});
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
const updateIds = notificationIds(visible);
const selectionControls = qs('#update-selection-controls');
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
qs('#cancel-work-selection').hidden = !workSelectionState.active;
const selectionScopeActions = qs('#selection-scope-actions');
selectionScopeActions.hidden = !workSelectionState.active;
qs('#select-matching-work').disabled = visible.length === 0 || workSelectionState.count >= workSelection.limit;
qs('#clear-work-selection').disabled = workSelectionState.count === 0;
qs('#select-matching-work').onclick = () => {
const result = workSelection.selectMany(visible);
qs('#my-work-action-status').textContent = result.status === 'limit' ?
result.count + ' matches selected. Selection is capped at ' + result.limit + ' items.' :
result.count + ' matches selected from the current queue and search.';
};
qs('#clear-work-selection').onclick = () => {
workSelection.clear();
qs('#my-work-action-status').textContent = 'Selection cleared. Choose new matches or individual work.';
qs('#select-matching-work').focus();
};
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
qs('#cancel-update-selection').hidden = !selection.active;
qs('#update-selection-status').textContent = workSelectionState.active ?
workSelectionState.count + ' selected' : (selection.active ?
selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.');
bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active);
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending || selection.count === 0;
document.querySelectorAll('[data-defer-selected]').forEach(button => {
button.hidden = workSelectionState.active;
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
});
qs('#batch-add-today').hidden = !workSelectionState.active;
qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin;
document.querySelectorAll('[data-batch-defer]').forEach(button => {
button.hidden = !workSelectionState.active;
button.disabled = workSelectionState.count === 0 || !planningOwnerLogin;
});
bulkButton.hidden = workSelectionState.active;
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + selection.count + ' selected read' :
'Mark ' + selection.count + ' selected read';
workRoute.setItems(lastMyWork);
}
function reviewFileElement(filename) {
return Array.from(document.querySelectorAll('.review-file')).find(
element => element.dataset.reviewFilename === filename
);
}
function applyReviewWrap(snapshot = wrapPreference.snapshot()) {
const reviewFilesElement = qs('#review-files');
const button = qs('#review-wrap-lines');
reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped);
button.setAttribute('aria-pressed', String(snapshot.wrapped));
button.textContent = snapshot.wrapped ? 'Lines wrapped' : 'Wrap lines';
}
function showReviewProgress(snapshot) {
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
qs('#review-progress').textContent = complete ?
'All files reviewed — finish in Gitea' :
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
document.querySelectorAll('.review-mark').forEach(button => {
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
button.setAttribute('aria-pressed', String(reviewed));
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
});
}
function openNextUnreviewed(snapshot) {
if (!snapshot?.nextFilename) return;
const file = reviewFileElement(snapshot.nextFilename);
if (!file) return;
const toggle = file.querySelector('.review-file-toggle');
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
createReviewController.toggleDiff(toggle, panel);
}
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
toggle?.focus();
}
const {paintIssueConversation,paintPullConversation,paintUpdateConversation}=createConversationRenderers({
qs,renderComment:(comment,controller)=>renderConversationComment(
comment,controller,escapeHtml,fmt,renderMarkdown),
updateReadPosition,getSelectedUpdate:()=>selectedUpdate,
});
function commentSurface(selector) {
if (selector === '#search-preview-comments') return {
context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}, pager:searchPreview.commentPager(),
render:state=>showSearchConversationWithActions(state), status:qs('#search-preview-conversation-status'),
};
if (selector === '#issue-comments') return {
context:{kind:'issue',item:selectedIssue}, pager:issueConversation,
render:renderIssueConversation, status:qs('#issue-sheet-status'),
};
if (selector === '#pull-comments') return {
context:{kind:'pull',item:selectedPull}, pager:pullConversation,
render:renderPullConversation, status:qs('#pull-sheet-status'),
};
return {
context:{kind:'update',item:selectedUpdate}, pager:notificationReader.commentPager(),
render:renderUpdateConversation, status:qs('#update-sheet-status'),
};
}
function wireCommentActions(selector, controller = commentActions) {
controller.wire({
root:qs(selector), getSurface:()=>commentSurface(selector),
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
});
}
const conversationActionSurfaces = {
issue: { selector:'#issue-comments', paint:paintIssueConversation },
pull: { selector:'#pull-comments', paint:paintPullConversation },
update: { selector:'#update-comments', paint:paintUpdateConversation },
};
const latestConversationStates = {};
function wireConversationActions(kind) {
if (kind === 'issue') wireCommentActions('#issue-comments');
if (kind === 'pull') wireCommentActions('#pull-comments');
if (kind === 'update') wireCommentActions('#update-comments');
}
function showConversationWithActions(kind, state) {
const surface = conversationActionSurfaces[kind];
latestConversationStates[kind] = state;
return actionHydrator.show({
root:qs(surface.selector), state, paint:surface.paint,
retry:qs('#retry-' + kind + '-comment-actions'),
wire:controller => wireConversationActions(kind, controller),
});
}
function renderIssueConversation(state) { void showConversationWithActions('issue', state); }
function renderPullConversation(state) { void showConversationWithActions('pull', state); }
function renderUpdateConversation(state) { void showConversationWithActions('update', state); }
function retryConversationActions(kind) {
const state = latestConversationStates[kind];
if (state) void showConversationWithActions(kind, state);
}
qs('#retry-issue-comment-actions').addEventListener('click', () => retryConversationActions('issue'));
qs('#retry-pull-comment-actions').addEventListener('click', () => retryConversationActions('pull'));
qs('#retry-update-comment-actions').addEventListener('click', () => retryConversationActions('update'));
function renderIssueLabelEditor(item, confirmedNames, labels) {
const list = qs('#issue-label-list');
const status = qs('#issue-label-status');
list.textContent = '';
const draftIds = issueController.loadLabelDraft(item);
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
);
list.innerHTML = labels.map(label =>
''
).join('');
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
qs('#save-issue-labels').disabled = false;
}
function selectedEditIssueLabelIds() {
return Array.from(document.querySelectorAll('input[name="issue-label"]:checked'))
.map(input => Number(input.value)).filter(Number.isInteger);
}
async function loadIssuePlanning() {
const item = selectedIssue;
const detail = selectedIssueDetail;
if (!item || !detail) return;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-milestone-status').textContent = 'Loading milestones…';
try {
const planning = await planningLoader.open(selectedIssue);
if (selectedIssue !== item) return;
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
issueController.renderMilestoneEditor(item, detail.milestone, planning.milestones);
} catch (_error) {
if (selectedIssue !== item) return;
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
qs('#retry-issue-planning').hidden = false;
}
}
function renderChecklistCompletion(detail) {
const interactive = !issueController.readOnly(selectedIssue) && detail?.state === 'open' && detail?.updated_at;
const completion = issueController.checklistCompletion(qs('#issue-sheet-body'), {
interactive, today:workSession.active(), offline:selectedIssueOffline,
dismissed:dismissedChecklistBody === detail?.body,
});
const bar = qs('#checklist-completion');
bar.hidden = !completion.visible;
qs('#checklist-completion-status').textContent = completion.status;
qs('#complete-checklist-issue').textContent = completion.label;
}
function renderIssueBody(detail) {
const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
qs('#open-add-checklist-step').disabled = !interactive;
renderChecklistCompletion(detail);
}
function applyIssueContent(editing, detail, confirmed) {
const merged = issueController.mergeContent(
lastContextSnapshot, editing, detail, confirmed, buildMyWork.replaceIssueContent
);
lastContextSnapshot = merged.snapshot;
selectedIssue = merged.item;
selectedIssueDetail = merged.detail;
qs('#issue-sheet-title').textContent = confirmed.title;
renderIssueBody(selectedIssueDetail);
if (lastContextSnapshot) paintMyWork(lastContextSnapshot);
}
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
const readOnly = issueController.readOnly(item);
const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';
const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&
Array.isArray(item.assignees) && item.assignees.length > 0;
qs('#issue-sheet').classList.toggle('read-only', readOnly);
qs('#issue-sheet').classList.toggle('filed-reassignable', reassignable);
issueDetailPosition.open(workDetailIdentity('issue', item));
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
void conversationPhotoDrafts.switchTo('issue',
{ kind:'issue', repository:item.repository, number:item.number }, () => selectedIssue === item);
void issueVoiceReply.open(conversationVoiceTarget('issue', item));
dismissedChecklistBody = null;
issueMentions.dismiss();
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
delegatedParentReview = null;
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
if (offlineDetail) {
qs('#watch-issue-detail').hidden = true;
qs('#issue-watch-status').textContent = 'Reconnect to change watch status.';
} else {
qs('#watch-issue-detail').hidden = item.state !== 'open';
void issueDetailWatch.open(item).catch(error => {
if (selectedIssue === item) qs('#issue-watch-status').textContent = error.message + ' Retry.';
});
}
qs('#issue-sheet-key').textContent = item.key || '';
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
qs('#issue-sheet-status').textContent = 'Loading issue…';
const completedItems = lastMyWork.filter(candidate => candidate?.is_completed && candidate?.is_filed);
const completedPosition = completedItems.findIndex(candidate =>
candidate.repository === item.repository && candidate.number === item.number
);
qs('#completed-filed-actions').hidden = !item.is_completed;
qs('#complete-parent-and-acknowledge').hidden = true;
qs('#complete-parent-and-acknowledge').disabled = true;
qs('#filed-claim-actions').hidden = true;
qs('#queue-filed-issue').disabled = true;
qs('#start-filed-issue').disabled = true;
qs('#completed-filed-progress').textContent = item.is_completed ?
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
qs('#issue-sheet-body').textContent = '';
checklistStepManagement.reset();
qs('#checklist-step-label').value = '';
qs('#open-add-checklist-step').disabled = true;
qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = '';
qs('#add-checklist-step-status').textContent = '';
qs('#checklist-completion').hidden = true;
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
qs('#load-older-issue-comments').hidden = true;
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-title').textContent = readOnly ? 'Add follow-up' : 'Add comment';
qs('#issue-comment-status').textContent = '';
qs('#issue-handoff').open = false;
qs('#issue-handoff-summary').textContent = reassignable ? 'Change delegate' : 'Hand off to teammate';
qs('#issue-handoff-recipient').innerHTML = '';
qs('#issue-handoff-recipient').disabled = true;
qs('#confirm-issue-handoff').disabled = true;
qs('#confirm-issue-handoff').textContent = reassignable ? 'Confirm change' :
(workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff');
qs('#load-issue-handoff').disabled = false;
qs('#issue-handoff-status').textContent = reassignable ?
'Load teammates to change the current delegate.' : 'Load teammates to transfer ownership.';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
qs('#save-issue-due-date').disabled = true;
qs('#clear-issue-due-date').disabled = true;
qs('#issue-due-status').textContent = 'Loading due date…';
qs('#issue-milestone').innerHTML = '';
qs('#issue-milestone').disabled = true;
qs('#save-issue-milestone').disabled = true;
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
setCommentNextVisibility('issue');
qs('#edit-issue-content').disabled = true;
qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = '';
qs('#close-issue').disabled = false;
qs('#close-issue').hidden = readOnly && !withdrawable;
qs('#close-issue').textContent = withdrawable ? 'Withdraw issue' : offlineDetail ?
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
(workSession.active() ? 'Close & next' : 'Close issue');
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#close-issue-sheet').focus();
try {
const loadedDetail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return;
const detail = issueController.pendingTask(
item, loadedDetail, authoredOutbox.list(), confirmedOwnerLogin
);
selectedIssueDetail = detail;
renderPlanIssueDependencies(detail);
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
renderIssueBody(detail);
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'' + escapeHtml(label) + ''
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
const claimableFiling = !offlineDetail && filedClaimEligible(item, detail);
qs('#filed-claim-actions').hidden = !claimableFiling;
qs('#queue-filed-issue').disabled = !claimableFiling;
qs('#start-filed-issue').disabled = !claimableFiling;
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
else renderIssueConversation(issueConversation.snapshot());
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = item.is_completed ?
'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' :
'Issue ready · ' + (detail.state || 'open');
const parentReference = item.is_completed && !offlineDetail ?
issueController.delegatedParentReference(detail) : null;
if (parentReference) {
try {
const parentItem = { ...parentReference, key:parentReference.repository + '#' + parentReference.number };
const parentDetail = await issueController.load(parentItem);
if (selectedIssue !== item) return;
const relationship = issueController.resolveDelegatedParent(item, detail, parentDetail);
if (relationship && parentDetail.state === 'open' && parentDetail.updated_at) {
delegatedParentReview = { parentItem, parentDetail, relationship };
const button = qs('#complete-parent-and-acknowledge');
button.hidden = false;
button.disabled = false;
button.textContent = relationship.checklistComplete ?
'Complete & close parent · acknowledge' : relationship.alreadyCompleted ?
'Parent complete · acknowledge' : 'Complete parent step & acknowledge';
qs('#completed-filed-progress').textContent = relationship.checklistComplete ?
'Delegated outcome completes the parent checklist. Confirm to close the parent.' :
relationship.alreadyCompleted ? 'Parent checklist already reflects this outcome.' :
'Delegated outcome ready · complete its parent checklist step.';
}
} catch (_error) {
if (selectedIssue !== item) return;
delegatedParentReview = null;
}
}
const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';
qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';
qs('#edit-issue-content').disabled = readOnly && !revisableFiling;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
qs('#issue-due-date').disabled = false;
qs('#save-issue-due-date').disabled = false;
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + formatCalendarDueDate(detail.due_date) : 'No due date set.';
if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
if (offlineDetail) {
setOfflineDetailControls('issue');
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#retry-issue-load').focus();
}
}
function closeIssueSheet(navigate = true) {
if (navigate && issueEditHistoryActive) {
history.back();
return;
}
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueVoiceReply.cancel();
void conversationPhotoDrafts.leave('issue');
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueOffline = false;
selectedIssueDetail = null;
issueConversation = null;
setTimeout(()=>issueTrigger?.focus());
}
function closeIssueEditorFromHistory() {
if (!issueEditHistoryActive) return;
issueEditHistoryActive = false;
qs('#issue-edit-form').hidden = true;
if (selectedIssue) qs('#edit-issue-content').focus();
}
window.addEventListener('popstate', closeIssueEditorFromHistory);
function acknowledgeCompletedFiled() {
if (!selectedIssue?.is_completed) return;
if (!completedFiledReview.acknowledge(selectedIssue)) {
qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.';
return;
}
const acknowledged = selectedIssue;
closeIssueSheet(false);
refreshMyWorkView();
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
' Filed review is complete. Acknowledgement sync pending.');
void syncCompletedFiledReviews();
if (target) {
const index = lastMyWork.indexOf(target.item);
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
} else {
if (mobileStartDay.completePhase('filed')) return;
window.location.hash = '#/my-work/filed';
qs('#my-work').focus();
}
}
qs('#acknowledge-completed-filed').addEventListener('click', acknowledgeCompletedFiled);
qs('#complete-parent-and-acknowledge').addEventListener('click', async () => {
if (!selectedIssue?.is_completed || !delegatedParentReview) return;
const button = qs('#complete-parent-and-acknowledge');
const { parentItem, parentDetail, relationship } = delegatedParentReview;
if (relationship.checklistComplete && !window.confirm(
'Complete the final checklist step and close ' + parentItem.key + '?'
)) return;
button.disabled = true;
try {
await issueController.finishDelegatedParentReview({
relationship, parentItem, parentDetail, childUrl:selectedIssue.url,
updateContent:(item, payload) => issueController.updateContent(item, payload),
closeParent:item => issueController.close(item),
});
acknowledgeCompletedFiled();
} catch (error) {
if (!selectedIssue) return;
button.disabled = false;
qs('#issue-sheet-status').textContent =
'Parent review action did not finish. Reload the parent relationship and retry. ' + error.message;
button.focus();
}
});
function renderCheckSection(prefix, detail, offline = false) {
const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline });
qs('#' + prefix + '-checks-summary').textContent = rendered.summary;
qs('#' + prefix + '-check-list').innerHTML = rendered.html || 'No individual checks were reported.
';
qs('#' + prefix + '-checks').open = rendered.expanded;
}
function renderPullReview(detail, focusFilename = null) {
renderCheckSection('pull', detail);
pullReviewState = pullController.reviewState(selectedPull, detail);
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
const eligibility = createPullSheet.review(detail, pullReviewState, document);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml)
).join('') : 'No changed files reported.
';
qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => {
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls'));
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
if (panel) panel.hidden = expanded;
});
});
qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => {
button.addEventListener('click', () => {
const filename = button.dataset.pullReviewFile;
pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename);
renderPullReview(detail, filename);
});
});
if (focusFilename) {
Array.from(qs('#pull-files').querySelectorAll('.pull-review-file'))
.find(button => button.dataset.pullReviewFile === focusFilename)?.focus();
}
}
function renderPullCheckStatus(status) {
qs('#pull-ci-state').textContent = 'CI ' + (status.ci_state || 'unknown');
renderCheckSection('pull', status);
if (status.head_sha !== selectedPullDetail?.head_sha) {
qs('#pull-review-status').textContent = 'New commits detected. Reload review data before merging.';
qs('#pull-merge-state').textContent = 'Review data is stale';
qs('#merge-pull').disabled = true;
return;
}
selectedPullDetail = { ...selectedPullDetail, ...status };
renderPullReview(selectedPullDetail);
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
}
async function loadPullReview({ refresh = false } = {}) {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const readDetail = selectedPullDetail;
qs('#pull-review-status').textContent = 'Loading changed files and merge readiness…';
qs('#pull-review-retry').hidden = true;
qs('#merge-pull').disabled = true;
try {
const review = await pullController.loadReview(item, readDetail.head_sha, { refresh });
if (selectedPull !== item) return;
selectedPullDetail = { ...readDetail, ...review };
qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown');
renderPullReview(selectedPullDetail);
qs('#pull-review-status').textContent = review.head_sha === readDetail.head_sha ?
'Review data ready for the current head.' : 'New commits detected. Review progress restarted for the latest head.';
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-review-status').textContent = error.message + ' Reading and replies remain available; retry here.';
qs('#pull-review-retry').hidden = false;
qs('#pull-merge-state').textContent = 'Review data unavailable';
qs('#merge-pull').disabled = true;
}
}
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item));
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
selectedPull = item;
void conversationPhotoDrafts.switchTo('pull',
{ kind:'pull', repository:item.repository, number:item.number }, () => selectedPull === item);
void pullVoiceReply.open(conversationVoiceTarget('pull', item));
pullMentions.dismiss();
pullTrigger = trigger;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
if (offlineDetail) {
qs('#watch-pull-detail').hidden = true;
qs('#pull-watch-status').textContent = 'Reconnect to change watch status.';
} else {
qs('#watch-pull-detail').hidden = item.state !== 'open';
void pullDetailWatch.open(item).catch(error => {
if (selectedPull === item) qs('#pull-watch-status').textContent = error.message + ' Retry.';
});
}
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-review').open = false;
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
qs('#pull-review-retry').hidden = true;
qs('#pull-review-progress').textContent = 'Review progress unavailable.';
qs('#next-unreviewed-pull-file').disabled = true;
qs('#pull-comments').textContent = '';
qs('#load-older-pull-comments').hidden = true;
qs('#pull-conversation-status').textContent = 'Loading newest messages…';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-checks-summary').textContent = 'Not loaded';
qs('#pull-check-list').textContent = '';
qs('#pull-checks').open = false;
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
createPullSheet.resetOwnershipControls(document, item, candidate => workSession.checkpointed(candidate));
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
setCommentNextVisibility('pull');
qs('#close-pull-sheet').focus();
try {
const detail = offlineDetail || await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
pullController.setReviewDetail(detail);
pullConversation = pullController.conversation(item, detail.conversation);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
if (offlineDetail) {
setOfflineDetailControls('pull');
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-pull-load').hidden = false;
qs('#retry-pull-load').focus();
}
}
function closePullSheet(navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
pullVoiceReply.cancel();
void conversationPhotoDrafts.leave('pull');
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
function selectedIssueLabelIds() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => Number(input.value)).filter(Number.isInteger);
}
function selectedIssueLabelNames() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => input.closest('label')?.querySelector('span')?.textContent?.trim()).filter(Boolean);
}
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
const selection = findWorkController.selection();
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
const selected = findWorkController.isSelected(item);
const detailId = 'find-work-detail-' + index;
const detail = '' + renderMarkdown(item.body || 'No description provided.') + '
' +
(item.url ? '
Open in Gitea' : '') +
'
';
return '' +
(selection.active ? '' : '') +
'' + escapeHtml(item.repository) + '#' +
Number(item.number) + '
' + escapeHtml(item.title || 'Untitled issue') + '' +
'' + (item.labels || []).map(label => '' + escapeHtml(label) + '').join(' ') +
'
' +
detail + '';
}).join('') : 'No unassigned issues are available on this page.
';
list.querySelectorAll('[data-find-work-select]').forEach(input => {
input.addEventListener('change', () => {
const item = findWorkController.items()[Number(input.dataset.findWorkSelect)];
if (item) findWorkController.toggleSelection(item);
});
});
list.querySelectorAll('[data-preview-index]').forEach(button => {
button.addEventListener('click', () => {
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
if (!item) return;
const expanded = findWorkController.togglePreview(item);
const detail = document.getElementById(button.getAttribute('aria-controls'));
button.setAttribute('aria-expanded', String(expanded));
button.textContent = expanded ? 'Hide details' : 'View details';
if (detail) detail.hidden = !expanded;
});
});
list.querySelectorAll('[data-claim-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
if (!item) return;
button.disabled = true;
try {
const confirmed = await findWorkController.claim(item);
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
openRoutedWork(claimed, qs('#find-work'));
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
button.disabled = false;
button.focus();
}
});
});
list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
if (!item) return;
const claimButtons = button.closest('.find-work-card')
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
const outcome = await assignAndStart.run(item, { destination:'queue' });
if (outcome === 'queued') {
(qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
}
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
claimButtons.forEach(action => { action.disabled = false; });
button.focus();
}
});
});
list.querySelectorAll('[data-claim-start-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimStartIndex)];
if (!item) return;
const claimButtons = button.closest('.find-work-card')
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
await assignAndStart.run(item);
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
claimButtons.forEach(action => { action.disabled = false; });
button.focus();
}
});
});
}
async function openFindWorkSheet(navigate = true) {
if (navigate) {
taskOverlayHistory.open('find');
return;
}
findingWork = true;
qs('#find-work-sheet').classList.add('open');
findWorkNavigation.open(batchFindWork.pending() > 0);
const retainedItems = findWorkController.items();
if (retainedItems.length) renderAvailableIssues(retainedItems);
else qs('#find-work-list').textContent = '';
qs('#find-work-status').textContent = retainedItems.length ?
'Refreshing available issues…' : 'Loading available issues…';
qs('#load-more-available').hidden = true;
qs('#close-find-work').focus();
try {
const result = await findWorkController.load();
if (!result?.stale) {
qs('#find-work-status').textContent = findWorkController.items().length ?
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
'No unassigned issues are available.';
}
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Close and retry.';
}
}
function closeFindWorkSheet(navigate = true) {
if (navigate && taskOverlayHistory.current() === 'find') {
taskOverlayHistory.close();
return;
}
findingWork = false;
qs('#find-work-sheet').classList.remove('open');
qs('#find-work').focus();
}
let issueCaptureBlockers = [];
function saveIssueCaptureDraft() {
if (issueCapture) issueCapture.saveDraft(currentIssueCaptureDraft(false));
}
function currentIssueCaptureDraft(trim = true) {
return withFilingEstimate(issueTemplatePicker.fields(
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, trim)
), qs('#create-issue-estimate').value);
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
let issueRepositorySearchTimer = null;
let captureBlockerSearchTimer = null;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
const selected = select.value;
const known = new Set(issueCaptureRepositories);
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository || known.has(repository)) return;
known.add(repository);
issueCaptureRepositories.push(repository);
const option = document.createElement('option');
option.value = repository;
option.textContent = repository;
select.appendChild(option);
});
if (selected) select.value = selected;
}
function updateIssueCreateActions() {
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
issueCaptureBlockers.length > 0, createAndStart.available());
}
function renderIssueCaptureBlockers(blockers) {
issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : [];
const selected = qs('#create-issue-blocker-selected');
selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => {
const item = document.createElement('li');
item.className = 'create-issue-blocker-selected';
const text = document.createElement('span');
text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove';
remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number);
remove.addEventListener('click', () => {
renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index));
saveIssueCaptureDraft();
});
item.append(text, remove);
return item;
}));
qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ?
issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') +
' selected. Blocked work will be created without starting.' : 'No blockers selected.';
updateIssueCreateActions();
}
function renderIssueCaptureBlockerResults(items) {
const results = qs('#create-issue-blocker-results');
results.replaceChildren();
(Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker =>
blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'create-issue-blocker-result';
button.setAttribute('role', 'option');
button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title;
button.addEventListener('click', () => {
renderIssueCaptureBlockers([...issueCaptureBlockers, {
repository:candidate.repository, number:Number(candidate.number), title:candidate.title,
}]);
results.hidden = true;
qs('#create-issue-blocker-search').value = '';
saveIssueCaptureDraft();
});
results.appendChild(button);
});
results.hidden = !results.childElementCount;
}
function renderIssueRepositoryResults(items) {
const results = qs('#create-issue-repository-results');
results.replaceChildren();
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'create-issue-repository-result';
button.setAttribute('role', 'option');
button.textContent = repository;
button.addEventListener('click', () => selectIssueCaptureRepository(repository));
results.appendChild(button);
});
results.hidden = !results.childElementCount;
}
function selectIssueCaptureRepository(repository) {
appendIssueRepositories([{full_name: repository}]);
qs('#create-issue-repository').value = repository;
qs('#create-issue-repository-search').value = repository;
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
issueOwnerPicker.reset(repository);
loadIssueFilingMetadata(repository);
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
qs('#create-issue-title').focus();
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
const panel = qs('#create-issue-duplicates');
const list = qs('#create-issue-duplicate-list');
const status = qs('#create-issue-duplicate-status');
const anyway = qs('#create-issue-anyway');
list.replaceChildren();
anyway.hidden = true;
if (state.status === 'idle') {
panel.hidden = true;
return;
}
panel.hidden = false;
if (state.status === 'failed') {
status.textContent = 'Could not check for existing issues. You can still create this issue.';
return;
}
if (!state.candidates.length) {
status.textContent = 'No similar open issues found in this repository.';
return;
}
status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
state.candidates.forEach(item => {
const card = document.createElement('article');
card.className = 'create-issue-duplicate-card';
const key = document.createElement('span');
key.className = 'small';
key.textContent = item.repository + ' #' + item.number;
const link = document.createElement('a');
link.textContent = item.title || 'Review existing issue';
link.href = safeSearchUrl(item.url) || '#';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.addEventListener('click', saveIssueCaptureDraft);
card.append(key, link);
list.appendChild(card);
});
}
function scheduleIssueDuplicateCheck() {
clearTimeout(duplicateCheckTimer);
const draft = currentIssueCaptureDraft();
duplicateCheckTimer = setTimeout(async () => {
renderIssueDuplicates(await issueCapture.findDuplicates(draft));
}, 350);
}
async function loadIssueFilingMetadata(repository, selected = {}) {
return issueFilingMetadata.load(repository, selected);
}
function clearSharedLaunchUrl() {
const cleanUrl = location.pathname + location.hash;
history.replaceState(history.state || {}, '', cleanUrl);
}
function openStagedSharedContent() {
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
openCreateIssueSheet();
if (sharedLaunchState.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
sharedLaunchState = {status: 'shown'};
return;
}
clearSharedLaunchUrl();
sharedLaunchState = null;
}
let createIssueModalLifecycle = null;
let createIssueLauncher = null;
function issueCaptureModal() {
if (createIssueModalLifecycle) return createIssueModalLifecycle;
const root = qs('#create-issue-sheet');
const background = Array.from(document.body.children).filter(element =>
element !== root && element.tagName !== 'SCRIPT' && element.getAttribute('role') !== 'dialog'
);
createIssueModalLifecycle = createIssueCapture.createModalLifecycle({
root, background, document, requestClose:() => closeCreateIssueSheet(),
});
return createIssueModalLifecycle;
}
async function openCreateIssueSheet(navigate = true) {
const root = qs('#create-issue-sheet');
if (!root.classList.contains('open') && document.activeElement && !root.contains(document.activeElement)) {
createIssueLauncher = document.activeElement;
}
if (!issueCapture && !await ensureIssueCapture()) return;
if (navigate) {
taskOverlayHistory.open('new');
return;
}
if (!sharedImageHandled && sharedImageMarker === 'bundle') {
sharedImageHandled = true;
if (createIssueAttachmentController.state()) {
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshots.';
} else {
await sharedImageCapture.consume({
marker:sharedImageMarker,
store:unfiledAttachmentStore,
restore:value=>createIssueAttachmentController.restore(value),
restoreContent:content=>{
sharedLaunchState = issueCapture.stageSharedContent(content);
sharedLaunchHandled = true;
},
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
});
}
clearSharedLaunchUrl();
}
const captureDraft = issueCapture.loadDraft();
issueTemplatePicker.reset(captureDraft);
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
issueCaptureRepositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = '' + issueCaptureRepositories.map(repository =>
''
).join('');
if (nextIssueRepositoryPage === 2) {
moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true;
}
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
else qs('#create-issue-repository').value = '';
qs('#create-issue-repository-search').value = captureDraft.repository || '';
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
setIssueFilingMode(Boolean(captureDraft.repository));
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
renderIssueCaptureBlockers(captureDraft.blockers || []);
qs('#create-issue-blocker-search').value = '';
qs('#create-issue-blocker-results').hidden = true;
loadIssueFilingMetadata(qs('#create-issue-repository').value, captureDraft);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
updateIssueCreateActions();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
issueCaptureModal().open(createIssueLauncher);
if (!sharedImageHandled && sharedImageMarker) {
sharedImageHandled = true;
if (createIssueAttachmentController.state()) {
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshot.';
} else {
await sharedImageCapture.consume({
marker:sharedImageMarker,
store:unfiledAttachmentStore,
restore:value=>createIssueAttachmentController.restore(value),
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
});
}
clearSharedLaunchUrl();
}
const mobileCapture = window.matchMedia?.('(max-width: 600px)').matches === true;
const continuingCapture = Boolean(rUC || captureDraft.title || captureDraft.body || captureDraft.repository);
if (unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})) {
qs('#create-issue-title').focus();
}
}
function setIssueFilingMode(enabled) {
applyIssueFilingMode(qs, enabled);
}
let suppressCreateDraftOnHistoryClose = false;
async function startChecklistPromotion(context) {
if (!issueCapture && !await ensureIssueCapture()) throw new Error('Issue capture is unavailable.');
checklistPromotion.start(context);
await openCreateIssueSheet();
}
const issueFilingReceipt = createIssueFilingReceipt({
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
filedButton:qs('#issue-filing-receipt-filed'),
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
fileAnotherButton:qs('#issue-filing-receipt-another'),
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
navigator,
onFileRelated:plan => {
issueCapture.saveDraft(plan);
createIssueAttachmentController.clear();
qs('#new-issue').click();
},
onFileAnother:() => qs('#new-issue').click(),
onViewFiled:issue => {
selectMobileQueue('filed');
openRoutedWork({
...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin),
});
},
});
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {
suppressCreateDraftOnHistoryClose = !preserveDraft;
taskOverlayHistory.leave();
return;
}
qs('#create-issue-sheet').classList.remove('open');
voiceIssueCapture.cancel();
mobileComposerViewport.close(qs('.create-issue-panel'));
clearTimeout(duplicateCheckTimer);
qs('#create-issue-duplicates').hidden = true;
creatingIssue = false;
issueCaptureModal().close({restore:!followUpSourceUpdate});
createIssueLauncher = null;
if (createAndStartRequested) timerView.transferCapture();
else timerView.finishCapture();
if (followUpSourceUpdate) {
const source = followUpSourceUpdate;
followUpSourceUpdate = null;
notificationReader.open(source.item, source.detail);
return;
}
}
function applyOutboxResult(result, openCreated = false, startCreated = false) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
(result.confirmed || []).forEach(confirmed => {
confirmed.work_reasons = ['created_by_me'];
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
});
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
const startedCompletions = new Set();
(result.completions || []).forEach(completion => {
if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return;
const created = lastMyWork.find(item => item.kind === 'issue' &&
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
if (!created) return;
const outcome = createAndStart.complete(created, completion.estimateMinutes);
if (outcome === 'started' || outcome === 'exists') {
issueOutbox.completeIntent(completion.id, activeFlushLogin);
startedCompletions.add(created.repository + '#' + created.number);
}
});
if (result.confirmed?.length) {
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
const confirmed = result.confirmed[result.confirmed.length - 1];
(result.filings || []).filter(filing => filing.relatedDraft?.checklistPromotion).forEach(filing => {
void checklistPromotion?.finish(filing.issue, filing.relatedDraft.checklistPromotion);
});
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
if (!(confirmed.assignees || []).includes(activeFlushLogin)) {
const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository &&
candidate.issue?.number === confirmed.number);
issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft);
}
if (startCreated && !(result.completions || []).length && created) {
const outcome = createAndStart.complete(created);
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
} else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue'));
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
} else {
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
}
}
async function flushIssueOutbox() {
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
}
function applyAuthoredOutboxResult(result) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
refreshMyWorkView();
const attention = (result.remaining || []).some(item => item.status === 'attention');
qs('#my-work-action-status').textContent = result.confirmed?.length ?
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
'Message queued for sync when the connection returns.';
}
async function flushAuthoredOutbox() {
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
}
async function flushNotificationReadOutbox() {
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
const result = await notificationReadOutbox.flush(activeFlushLogin);
if (result.confirmed?.length) {
qs('#my-work-action-status').textContent = result.confirmed.length +
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
}
}
function canQueueMessage(error) {
const status = Number(error?.status || 0);
return !status || status >= 500;
}
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
};
}
function sameInlineAnchor(comment, anchor) {
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
comment.old_position === anchor.old_position;
}
function closeInlineComposer() {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-inline-composer').hidden = true;
qs('#review-inline-body').value = '';
activeInlineTarget = null;
}
function openInlineComposer(target) {
if (!draft) return;
activeInlineTarget = target;
const anchor = inlineAnchor(target);
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
qs('#review-inline-body').value = existing?.body || '';
qs('#delete-inline-comment').disabled = !existing;
qs('#review-inline-composer').hidden = false;
qs('#review-inline-body').focus();
}
async function openReviewSheet(item, trigger, cachedDetail = null) {
if (!item || !await ensurePullWorkflow(trigger)) return;
reviewDetailPosition.open(workDetailIdentity('review', item));
mobileReviewDetailNavigation.reset();
selectedReview = item;
reviewTrigger = trigger;
offlineReview = Boolean(cachedDetail);
qs('#review-sheet').classList.add('open');
qs('#review-sheet-key').textContent = item.key;
qs('#review-sheet-title').textContent = item.title;
qs('#review-sheet-status').textContent = 'Loading review details…';
qs('#retry-review-load').hidden = true;
qs('#review-sheet-body').textContent = '';
qs('#review-files').textContent = '';
applyReviewWrap();
qs('#review-progress').textContent = '0 of 0 files reviewed';
qs('#next-unreviewed-review').disabled = true;
qs('#review-history').textContent = '';
qs('#open-review-gitea').href = item.url;
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
qs('#review-copy-fallback').hidden = true;
qs('#review-copy-fallback').value = '';
qs('#review-handoff-link').hidden = true;
qs('#review-handoff-link').href = item.url;
qs('#review-handoff-status').textContent = '';
qs('#review-submit-status').textContent = '';
qs('#continue-review-to-merge').hidden = true;
qs('#review-ci-state').textContent = 'CI unknown';
qs('#review-checks-summary').textContent = cachedDetail ? 'Last known · loading' : 'Loading';
qs('#review-check-list').textContent = '';
qs('#review-checks').open = false;
qs('#refresh-review-checks').disabled = offlineReview;
qs('#submit-review').disabled = true;
qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' :
(offlineReview ? 'Queue review for reconnect' : 'Submit review');
closeInlineComposer();
draft = null;
reviewFiles = [];
selectedReviewHead = '';
qs('#close-review-sheet').focus();
try {
const detail = cachedDetail || await reviewController.load(selectedReview);
if (selectedReview !== item) return;
qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail, offlineReview);
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createReviewController.renderDiffFile(file, index, escapeHtml)
).join('') : 'No changed files reported.
';
document.querySelectorAll('.review-file-toggle').forEach(button => {
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls'));
if (panel) createReviewController.toggleDiff(button, panel);
});
});
progress = createReviewController.createProgress({
storage: localStorage,
repository: item.repository,
number: item.number,
headSha: detail.head_sha || 'unknown',
files: detail.files || [],
});
reviewFiles = detail.files || [];
selectedReviewHead = detail.head_sha || '';
draft = createReviewController.createDraft({
storage: localStorage,
repository: item.repository,
number: item.number,
headSha: detail.head_sha || 'unknown',
files: reviewFiles,
});
const draftSnapshot = draft.snapshot();
qs('#review-decision').value = draftSnapshot.decision;
qs('#review-summary').value = draftSnapshot.summary;
document.querySelectorAll('.review-note').forEach(note => {
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
});
document.querySelectorAll('.review-inline-target').forEach(target => {
const anchor = inlineAnchor(target);
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
sameInlineAnchor(comment, anchor)
));
target.addEventListener('click', () => openInlineComposer(target));
});
document.querySelectorAll('.review-mark').forEach(button => {
button.addEventListener('click', () => {
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
showReviewProgress(snapshot);
openNextUnreviewed(snapshot);
});
});
const progressSnapshot = progress.snapshot();
showReviewProgress(progressSnapshot);
openNextUnreviewed(progressSnapshot);
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
'' + escapeHtml(review.user?.login || 'Reviewer') + ' · ' +
escapeHtml(review.state || 'commented') + (review.body ? '
' + renderMarkdown(review.body) + '
' : '') + '
'
).join('') : 'No prior reviews.
';
qs('#review-sheet-status').textContent = offlineReview ?
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = false;
} catch (error) {
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
qs('#retry-review-load').hidden = false;
qs('#retry-review-load').focus();
}
}
function closeReviewSheet(navigate = true) {
if (updateReviewHandoff.active()) {
updateReviewHandoff.cancel();
return;
}
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
closeInlineComposer();
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
function closeUpdateSheet(restoreTrigger = true, navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
updateVoiceReply.cancel();
void conversationPhotoDrafts.leave('update');
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
selectedUpdateDetail = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
else qs('[data-work-filter="update"]')?.focus();
}
function markMyWorkStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastMyWork.length ?
'Update failed · showing last known work' : 'Work inbox unavailable.';
}
function markNotificationsStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastNotifications.length ?
'Unread updates unavailable · showing last known updates' :
'Unread updates unavailable · assigned work is fresh';
}
function paintDeltas(deltas) {
const el = qs('#ai');
el.innerHTML = deltas.length ? deltas.map(d => '' + escapeHtml(d.priority) + ' ' + escapeHtml(d.action) + ' ' + escapeHtml(d.target || '') + '
' + escapeHtml(d.panel) + '
').join('') : 'No suggestions yet.
';
}
function paintEventStream(events) {
const el = qs('#gitea-events');
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
'' + escapeHtml(event.actor?.login || 'Gitea') + ' ' +
escapeHtml(event.type || 'activity') +
'
' + escapeHtml(event.repo?.full_name || '') +
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '
'
).join('') : 'No recent Gitea activity.
';
}
function setEventStreamStatus(message) {
qs('#gitea-events-status').textContent = message;
}
async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
setOfflineWorkMode(false);
offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context;
const eventsFreshness = snapshot.freshness?.sections?.events;
const notificationFreshness = snapshot.freshness?.sections?.notifications;
const contextChanged = changedSections.includes('context');
const notificationsChanged = changedSections.includes('notifications');
const eventsChanged = changedSections.includes('events');
const workChanged = contextChanged || notificationsChanged;
const hasNotifications = Array.isArray(snapshot.notifications);
const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
if (notificationsChanged && hasNotifications) {
if (notificationPagination.page > 1) {
const byId = new Map(lastNotifications.map(item => [item.id, item]));
snapshot.notifications.forEach(item => byId.set(item.id, item));
lastNotifications = Array.from(byId.values());
} else {
lastNotifications = snapshot.notifications;
}
if (snapshot.notification_pagination) {
const page = notificationPagination.page > 1 ? notificationPagination.page :
snapshot.notification_pagination.page;
notificationPager.reset({
page,
total: snapshot.notification_pagination.total,
has_more: page * 50 < snapshot.notification_pagination.total,
});
}
}
if (snapshot.context) {
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
updateDeliveryReceiptControls();
interruptionPrompt.restore();
}
}
if (snapshot.context && workChanged) {
setOfflineWorkMode(false);
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
timerView.render();
updatePlanningAvailability();
if (planningOwnerLogin) {
syncPendingTomorrow();
todaySync.migrate(todayWork.read());
todaySync.flush();
laterSync.migrate(laterWork.read());
laterSync.flush();
}
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale();
if (!contextFreshness?.stale && notificationsFresh) {
const admitted = await offlineWorkStore.save({
...snapshot.context,
notifications: snapshot.notifications,
notification_pagination: snapshot.notification_pagination,
});
await updateOfflineWorkControls(admitted === false ?
'Offline saving unavailable · retry after reconnect.' : undefined);
warmTodayOffline();
}
flushIssueOutbox();
flushAuthoredOutbox();
flushNotificationReadOutbox();
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
setEventStreamStatus('Refreshing activity · showing last activity');
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) {
const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0;
setEventStreamStatus('Activity refresh failed · showing last activity' +
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
} else if (Array.isArray(snapshot.events)) {
setEventStreamStatus('Updated ' + fmt(new Date()));
} else {
setEventStreamStatus('Update failed · showing last activity');
}
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
setEventStreamStatus('Refresh failed · showing last known data' +
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
setEventStreamStatus('Refreshing · showing recent snapshot');
}
renderLiveDataStatus(snapshot.freshness || {});
}
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function escAttr(s) { return escapeHtml(s); }
function openModal(id) { qs('#' + id).classList.add('open'); }
function closeModal(id) { qs('#' + id).classList.remove('open'); }
(function bg(){
const c=qs('#bg'),ctx=c.getContext('2d');
let w,h,time=0;
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
resize(); addEventListener('resize', resize);
const draw=()=>{
time+=0.012;
ctx.clearRect(0,0,w,h);
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
const step=60;
for(let x=0;x ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
canvas.addEventListener('pointerup', () => drawing=false);
canvas.addEventListener('pointerleave', () => drawing=false);
qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
}
qs('#md-input').addEventListener('input', renderMD);
function renderMD() {
const raw = qs('#md-input').value || '';
qs('#md-preview').innerHTML = '' + escapeHtml(raw) + '
' + renderMarkdown(raw) + '
';
}
const commands = [
{ name: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Markdown', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh', run: load },
];
let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = [];
let commandSelection = -1;
let searchBatchPlanning = null;
async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}, continuation) {
const response = await fetch(filterCommands.searchUrl(query, page, scope, continuation), {
headers: { Accept:'application/json' },
signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
return payload;
}
const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork,
onState: state => {
commandSearchState = state;
renderCommands(state.query);
},
});
function currentSearchScope() {
return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value,
repository:qs('#cmd-search-repository').value.trim() };
}
function applySearchScope(scope = {kind:'all', state:'all'}) {
qs('#cmd-search-kind').value = scope.kind;
qs('#cmd-search-state').value = scope.state;
qs('#cmd-search-repository').value = scope.repository || '';
commandSearch.setScope(scope);
}
let searchPreviewDetail = null;
const showSearchConversationWithActions = createSearchPreviewConversationActions({
hydrator:actionHydrator, rootNode:qs('#search-preview-comments'),
retry:qs('#retry-search-preview-comment-actions'),
paint:(conversation,controller)=>renderSearchPreviewConversation(
conversation,document,escapeHtml,fmt,renderMarkdown,controller),
wire:controller=>wireCommentActions('#search-preview-comments',controller),
});
const renderSearchConversation = conversation => { void showSearchConversationWithActions(conversation); };
function renderSearchPreview(state) {
followingQueue.preview(state);
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const deferButton = qs('#defer-search-result');
const queueButton = qs('#queue-search-result');
const planButton = qs('#plan-search-result');
const startButton = qs('#start-search-result');
const watchButton = qs('#watch-search-result');
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : searchPreviewReturnKind === 'following' ? 'Back to Following' : 'Back to search';
if (state.status === 'closed') {
searchVoiceReply.cancel();
sheet.classList.remove('open');
return;
}
sheet.classList.add('open');
claimButton.hidden = true;
claimButton.disabled = false;
deferButton.hidden = true;
deferButton.disabled = false;
queueButton.hidden = true;
planButton.hidden = true;
planButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
watchButton.hidden = true;
watchButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewWorkspaces(state,null,searchPreview,document,escapeHtml);
if (state.status === 'loading') {
searchReplyAttachmentTarget = { ...state.item };
restoreSearchReplyPhotos(searchReplyAttachmentTarget);
searchVoiceReply.open(searchConversationVoiceTarget(state.item));
searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
qs('#search-preview-meta').textContent = '';
qs('#search-preview-body').textContent = '';
renderSearchConversation(null);
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
status.textContent = 'Loading preview…';
return;
}
if (state.status === 'error') {
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
return;
}
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
shareButton.disabled = state.status === 'sharing';
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
qs('#search-preview-meta').textContent =
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
(detail.author ? ' · by ' + detail.author : '') +
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
renderSearchPreviewWorkspaces(state,detail,searchPreview,document,escapeHtml);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
deferButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
deferButton.textContent = detail.assigned_to_me ? 'Defer' : 'Assign & defer';
deferButton.disabled = state.status === 'claiming' || searchDefer?.pending();
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
planButton.hidden = !searchWeekPlan.eligible(detail);
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
planButton.disabled = searchWeekPlan.pending();
renderSearchPreviewStart(detail, state, startButton);
renderSearchPreviewWatch(detail, state, watchButton);
followingQueue.preview(state);
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
};
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
else if (detail.claimable) status.textContent = 'Open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
const searchPreview = createSearchPreview({
fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
fetchReview:searchSubscription.review,
mutate:searchPreviewMutation(fetchReviewJson),
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
const serialized = await searchReplyAttachmentController.serialize();
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
const admitted = await authoredOutbox.enqueueDurably({
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
body,operationId,...(attachments.length ? {attachments} : {}),
});
renderOutbox();
return { id:admitted.item.id, queued:true };
},
prepareReply:(item,body) => {
searchReplyAttachmentTarget = item;
return searchReplyAttachmentController.prepareComment(item, body);
},
afterReply:item => {
if (!searchReplyDraftStore) return null;
return searchReplyDraftStore.remove(item);
},
hasAttachments:() => Boolean(searchReplyAttachmentController.state()),
clearAttachments:() => {
searchReplyAttachmentTarget = null;
searchReplyAttachmentController.clear();
},
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
getSession:() => followingQueue.session() || commandSearchState,
loadMore:() => commandSearch.loadMore(),
onNavigate:item => {
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
},
onOpened:item => followingQueue.previewLoaded(item),
afterUnwatch:item => followingQueue.retire(item),
navigationRoot:document,
onState: renderSearchPreview,
});
const searchWeekPlan = createSearchWeekPlan({
week:weekPlan,claim:detail=>searchPreview.claim(detail),accept:acceptClaimedIssue,
identity:item=>todayWork.identity(item),
});
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
getDetail:()=>searchPreviewDetail,
announce:message=>{qs('#search-preview-status').textContent=message;},
onPlanned:item=>(searchPreviewDetail=item,item.following&&next()),
});
searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker, weekPlan, localStorage
);
searchDefer = createSearchDefer({
claim: detail => searchPreview.claim(detail),
accept: confirmed => acceptClaimedIssue(confirmed),
defer: (item, until) => laterWork.defer(item, until),
refresh: refreshMyWorkView,
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
formatTime: fmt,
});
const canonicalSearchPreviewUrl = () => searchPreviewUrl(
taskOverlayHistory.currentState(), currentSearchScope(), window.location
);
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,
claim,
start: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(item);
},
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
recover: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(item, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const recoverSearchPull = createSearchAuthoredPullRecovery({
confirm,
reopen:detail => searchPreview.reopenPull(detail),
refresh:load,
find:detail => lastMyWork.find(item => item.key === detail.repository + '#' + detail.number),
open:item => openRoutedWork(item, qs('#start-search-result')),
unavailable:message => qs('#search-preview-status').textContent = message,
});
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
schedule: callback => requestAnimationFrame(callback),
});
const searchModal = createMobileSearchModal({document});
function closeSearchPreview(navigate = true) {
if (searchPreviewReturnKind === 'following') {
searchPreview.close();
searchPreviewReturnKind = null;
followingQueue.returnToFollowing();
return;
}
if (navigate && taskOverlayHistory.current() === 'search-preview') {
taskOverlayHistory.close();
return;
}
searchPreview.close();
qs('#cmd-palette').classList.add('open');
qs('#cmd-input').setAttribute('aria-expanded', 'true');
renderCommands(qs('#cmd-input').value);
searchModal.transition(qs('#cmd-palette'), { initialFocus:qs('#cmd-input') });
}
const next = searchPreview.next;
async function openPreviewWorkInMyWork(detail) {
await load();
const item = lastMyWork.find(candidate =>
candidate.kind === detail.kind && candidate.key === detail.repository + '#' + detail.number
);
if (!item) return false;
taskOverlayHistory.leave();
openRoutedWork(item, qs('#find-work'));
return true;
}
function runCommandItem(item) {
if (item.command) {
taskOverlayHistory.leave();
item.command.run();
qs('#cmd-input').value = '';
} else {
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
searchModal.transition(qs('#search-preview'), { initialFocus:qs('#close-search-preview') });
taskOverlayHistory.open('search-preview', {
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
});
}
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
}
function renderCommands(filter) {
const el = qs('#cmd-results');
const loadMore = qs('#cmd-load-more');
const selecting = searchBatchPlanning?.plan.snapshot().active === true;
const local = selecting ? [] : filterCommands(commands, filter).map(command => ({ command }));
const remote = commandSearchState.query === String(filter || '').trim()
? commandSearchState.items.map(result => ({ result })) : [];
commandItems = local.concat(remote);
if (commandSelection >= commandItems.length) commandSelection = -1;
let html = local.length ? 'Commands
' : '';
html += local.map((item, idx) => '' + escapeHtml(item.command.name) + 'Command
').join('');
if (remote.length) html += 'Issues and pull requests
';
html += remote.map((item, remoteIdx) => {
const idx = local.length + remoteIdx;
const result = item.result;
if (selecting) return searchBatchPlanning.resultHtml(result, idx);
return '' + escapeHtml(result.title) + '' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '
';
}).join('');
if (commandSearchState.status === 'loading') html += 'Searching accessible work…
';
else if (commandSearchState.status === 'error') html += 'Search unavailable. Keep typing or retry.
';
else if (commandSearchState.partial) html += 'Some results are temporarily unavailable.
';
else if (String(filter || '').trim().length >= 2 && !remote.length) html += 'No matching issues or pull requests.
';
el.innerHTML = html;
loadMore.hidden = !commandSearchState.more;
loadMore.textContent = commandSearchState.partial ? 'Retry missing results' : 'More results';
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
});
}
function openCommandPalette(navigate = true) {
if (navigate) {
taskOverlayHistory.open('search', { scope:currentSearchScope() });
return;
}
qs('#cmd-palette').classList.add('open');
mobileSearchViewport.open();
mobileSearchViewport.restoreScroll();
qs('#cmd-input').setAttribute('aria-expanded', 'true');
searchModal.activate(qs('#cmd-palette'), {
opener:document.activeElement,
initialFocus:qs('#cmd-input'),
});
commandSelection = -1;
renderCommands(qs('#cmd-input').value);
}
const taskOverlayHistory = createTaskOverlayHistory({
history: window.history,
eventTarget: window,
onChange(kind, previous, detail) {
if (previous === 'new' && kind !== 'new') {
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
suppressCreateDraftOnHistoryClose = false;
closeCreateIssueSheet(false);
}
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
if (previous === 'search-preview' && kind !== 'search-preview') {
if (kind === 'search') closeSearchPreview(false);
else {
searchPreview.close();
mobileSearchViewport.close();
searchModal.deactivate();
}
searchPreviewReturnKind = null;
}
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
mobileSearchViewport.close();
searchModal.deactivate();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
else if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
else planTodayPreview.close();
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
if (previous === 'today-readiness' && kind !== 'today-readiness') {
if (kind === 'search-preview') suspendTodayReadiness();
else closeTodayReadiness(false);
}
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) {
qs('#cmd-input').value = detail.query;
commandSearch.setQuery(detail.query);
}
openCommandPalette(false);
}
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
searchPreviewReturnKind = 'search';
searchModal.activate(qs('#search-preview'), {
opener:document.activeElement,
initialFocus:qs('#close-search-preview'),
});
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
}
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
},
});
taskOverlayHistory.start();
createSavedSearches.mount(
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
).load();
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
function changeSearchScope() {
const scope = currentSearchScope();
commandSelection = -1;
taskOverlayHistory.update({ scope });
commandSearch.setScope(scope);
}
qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
qs('#cmd-search-state').addEventListener('change', changeSearchScope);
let repositoryLookupController = null;
let repositoryLookupTimer = null;
async function suggestSearchRepositories() {
const input = qs('#cmd-search-repository');
const query = input.value.trim();
const status = qs('#cmd-repository-status');
if (repositoryLookupTimer !== null) clearTimeout(repositoryLookupTimer);
if (repositoryLookupController) repositoryLookupController.abort();
repositoryLookupController = null;
if (!query) {
qs('#cmd-search-repositories').innerHTML = '';
status.textContent = 'Searching all accessible repositories.';
changeSearchScope();
return;
}
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(query)) changeSearchScope();
if (query.length < 2) {
status.textContent = 'Enter at least 2 characters to find a repository.';
return;
}
status.textContent = 'Finding accessible repositories…';
repositoryLookupTimer = setTimeout(async () => {
const controller = new AbortController();
repositoryLookupController = controller;
try {
const response = await fetch('api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20', {
headers:{ Accept:'application/json' }, signal:controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Repository lookup unavailable.');
if (repositoryLookupController !== controller || input.value.trim() !== query) return;
const items = Array.isArray(payload.items) ? payload.items : [];
qs('#cmd-search-repositories').innerHTML = items.map(item =>
''
).join('');
status.textContent = items.length ? 'Choose an accessible repository.' : 'No accessible repositories found.';
} catch (error) {
if (error?.name !== 'AbortError') status.textContent = 'Repository lookup unavailable; global Search still works.';
} finally {
if (repositoryLookupController === controller) repositoryLookupController = null;
}
}, 200);
}
qs('#cmd-search-repository').addEventListener('input', suggestSearchRepositories);
qs('#cmd-search-repository').addEventListener('change', changeSearchScope);
qs('#cmd-search-repository').addEventListener('search', suggestSearchRepositories);
qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1;
taskOverlayHistory.update({ query:e.target.value });
renderCommands(e.target.value);
commandSearch.setQuery(e.target.value);
});
qs('#cmd-input').addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
renderCommands(event.currentTarget.value);
const selected = qs('#cmd-results .selected');
if (selected) selected.scrollIntoView({ block:'nearest' });
} else if (event.key === 'Enter' && commandSelection >= 0) {
event.preventDefault();
runCommandItem(commandItems[commandSelection]);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
e.preventDefault();
taskOverlayHistory.close();
return;
}
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
e.preventDefault();
closeSearchPreview();
return;
}
if (e.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
return;
}
if (e.key === 'Escape' && creatingIssue) {
e.preventDefault();
saveIssueCaptureDraft();
closeCreateIssueSheet();
return;
}
if (e.key === 'Escape' && selectedIssue) {
e.preventDefault();
if (!qs('#issue-edit-form').hidden) {
history.back();
return;
}
closeIssueSheet();
return;
}
if (e.key === 'Escape' && selectedPull) {
e.preventDefault();
closePullSheet();
return;
}
if (e.key === 'Escape' && selectedReview) {
e.preventDefault();
closeReviewSheet();
return;
}
if (e.key === 'Escape' && selectedUpdate) {
e.preventDefault();
closeUpdateSheet();
return;
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
if (qs('#cmd-palette').classList.contains('open')) {
taskOverlayHistory.close();
} else openCommandPalette();
}
});
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(() => {});
});
wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
try {
const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard);
status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.';
} catch (error) {
status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.';
}
});
});
qs('#claim-search-result').addEventListener('click', async () => {
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
try {
const claimed = searchPreviewDetail;
if (claimed.claimable) await searchPreview.claim(claimed);
const opened = await openPreviewWorkInMyWork(claimed);
if (!opened) {
qs('#search-preview-status').textContent = 'Refresh My Work to open this item.';
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#defer-search-result').addEventListener('click', event => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
laterPicker.open(detail, event.currentTarget, 'search');
});
qs('#queue-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
try {
if (todayWork.contains(detail)) {
qs('#cmd-search-action-status').textContent = 'Already in Today.';
await next();
return;
}
const outcome = await searchAssignAndStart.run(detail, {
alreadyOwned: detail.assigned_to_me,
destination: 'queue',
});
if (outcome === 'queued') await next();
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (detail?.authored_pull_reopenable) {
try {
await recoverSearchPull(detail);
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
return;
}
if (detail?.reviewable) {
taskOverlayHistory.leave();
detail.kind = 'review';
return openRoutedWork(detail, null, { replace:true });
}
if (!detail || detail.kind !== 'issue') return;
try {
if (detail.reopenable) await searchReopenAndStart.run(detail);
else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#fill-find-work-today').addEventListener('click', () => {
const remainingSlots = todayWork.limit - todayWork.read().length;
const outcome = findWorkController.fillSelection(remainingSlots);
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
if (outcome.selected) findWorkNavigation.go('review');
});
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
qs('#find-work-search').addEventListener('input', event => {
const value = event.currentTarget.value;
qs('#clear-find-work-search').hidden = !value;
clearTimeout(findWorkSearchTimer);
findWorkSearchTimer = setTimeout(async () => {
qs('#find-work-status').textContent = value.trim() ?
'Searching available issues…' : 'Loading available issues…';
try {
await findWorkController.search(value);
updateFindWorkMatchStatus();
qs('#find-work-status').textContent = availablePagination.total ?
'Available work loaded.' : 'No matching unassigned issues.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Retry search.';
}
}, 250);
});
qs('#clear-find-work-search').addEventListener('click', () => {
const input = qs('#find-work-search');
input.value = '';
input.dispatchEvent(new Event('input', { bubbles:true }));
input.focus();
});
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
button.disabled = false;
if (outcome.status === 'estimates-required') {
const first = outcome.invalid[0];
document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus();
return;
}
if (outcome.status !== 'complete') return;
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
const unavailable = outcome.failed.length - assignedOnly;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
if (!outcome.failed.length) {
findWorkNavigation.complete();
findWorkController.cancelSelection();
} else {
renderAvailableIssues(findWorkController.items());
findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true });
}
refreshMyWorkView();
});
qs('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';
try {
await findWorkController.loadMore();
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
availablePagination.total + ' available issues loaded.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
} finally {
event.currentTarget.disabled = false;
}
});
qs('#create-update-follow-up').addEventListener('click', async () => {
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
updateFollowUp.stageSource(source.item);
followUpSourceUpdate = source;
closeUpdateSheet(false, false);
await openCreateIssueSheet(false);
if (state.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
} else {
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
}
qs('#create-follow-up-next').hidden = false;
});
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
if (!qs('#create-issue-title').value.trim()) {
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
qs('#create-issue-title').focus();
return;
}
saveIssueCaptureDraft();
setIssueFilingMode(true);
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-repository-search').focus();
});
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
const evidence = await createIssueAttachmentController.serialize();
const captureDraft = currentIssueCaptureDraft();
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
if (showDraftCapacityDialog(unfiledCaptures, qs)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
const capture = timerView.finishCapture();
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
if (!capture) {
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
}
refreshMyWorkView();
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
if (!capture) requestAnimationFrame(() => {
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
qs('#my-work-action-status').textContent = unfiledSavedMessage(captureDraft);
} catch (error) {
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog({
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
});
qs('#use-shared-content').addEventListener('click', () => {
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
else issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
openCreateIssueSheet();
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
});
qs('#resume-issue-draft').addEventListener('click', () => {
issueCapture.discardSharedContent();
issueCapture.discardFollowUp();
updateFollowUp.discardSource();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
qs('#create-issue-status').textContent = 'Existing draft restored.';
qs('#create-issue-title').focus();
});
qs('#cancel-new-issue').addEventListener('click', () => {
if (checklistPromotion?.cancel()) {
closeCreateIssueSheet(true, false);
return;
}
const discardEditedDraft = Boolean(editingOutboxId);
if (editingOutboxId) {
issueCapture.clearDraft();
editingOutboxId = null;
} else saveIssueCaptureDraft();
closeCreateIssueSheet(true, !discardEditedDraft);
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
qs(selector).addEventListener('input', () => {
saveIssueCaptureDraft();
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
})
);
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
issueCapture.saveDraft({repository:'', labelIds:[],
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
progressiveCaptureHandoff.complete?.();
await openCreateIssueSheet(false);
}
qs('#create-issue-repository').addEventListener('change', event => {
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
issueOwnerPicker.reset(event.target.value);
loadIssueFilingMetadata(event.target.value);
qs('#create-issue-repository-search').value = event.target.value;
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
});
qs('#create-issue-repository-search').addEventListener('input', event => {
clearTimeout(issueRepositorySearchTimer);
const query = event.target.value.trim();
const results = qs('#create-issue-repository-results');
const status = qs('#create-issue-repository-status');
if (query.length < 2) {
issueCapture.searchRepositories(query);
results.hidden = true;
status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.';
return;
}
status.textContent = 'Searching accessible repositories…';
issueRepositorySearchTimer = setTimeout(async () => {
const state = await issueCapture.searchRepositories(query);
if (state.status === 'stale') return;
if (state.status === 'failed') {
results.hidden = true;
status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.';
return;
}
renderIssueRepositoryResults(state.items);
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
}, 250);
});
qs('#create-issue-blocker-search').addEventListener('input', event => {
clearTimeout(captureBlockerSearchTimer);
const query = event.target.value.trim();
const results = qs('#create-issue-blocker-results');
const status = qs('#create-issue-blocker-status');
if (query.length < 2) {
issueCapture.searchBlockers(query);
results.hidden = true;
status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' :
(query ? 'Enter at least 2 characters to search.' : 'No blockers selected.');
return;
}
if (issueCaptureBlockers.length >= 5) {
results.hidden = true;
status.textContent = 'Five blockers selected. Remove one to choose another.';
return;
}
status.textContent = 'Searching open issues…';
captureBlockerSearchTimer = setTimeout(async () => {
const state = await issueCapture.searchBlockers(query);
if (state.status === 'stale') return;
if (state.status === 'failed') {
results.hidden = true;
status.textContent = 'Blocker search failed. Your draft is safe; retry.';
return;
}
renderIssueCaptureBlockerResults(state.items);
status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues match.';
}, 250);
});
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
saveIssueCaptureDraft();
button.disabled = true;
status.textContent = 'Loading more repositories…';
try {
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
appendIssueRepositories(payload.items);
nextIssueRepositoryPage = payload.page + 1;
moreIssueRepositoriesAvailable = payload.has_more;
button.hidden = !moreIssueRepositoriesAvailable;
status.textContent = payload.items.length ?
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
} catch (_error) {
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-anyway').addEventListener('click', () => {
const captureDraft = currentIssueCaptureDraft();
issueCapture.acknowledgeDuplicates(captureDraft);
qs('#create-issue-anyway').hidden = true;
qs('#create-issue-form').requestSubmit();
});
async function admitReviewedIssue(review) {
const durableDraft = review.draft;
const promotion = checklistPromotion?.deliveryContext();
const deliveryDraft = {
...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft),
};
if (promotion) deliveryDraft.relatedDraft.checklistPromotion = promotion;
const followUpNextRequested = review.intent === 'follow-up-and-next';
createAndStartRequested = review.intent === 'create-and-start';
if (createAndStartRequested && !createAndStart.available()) {
throw new Error('Today is full now. Go back and remove an item before creating and starting another.');
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
const followUpButton = qs('#create-follow-up-next');
button.disabled = true;
startButton.disabled = true;
followUpButton.disabled = true;
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const admission = followUpNextRequested ? (await updateFollowUp.complete({
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
issueOutbox.enqueueDurably(deliveryDraft),
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
await issueOutbox.enqueueDurably(deliveryDraft));
const queued = admission.item;
pendingIssueFilingIntent = 'create-and-assign';
const fS = dFS.current();
if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
refreshMyWorkView();
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
return;
}
editingOutboxId = null;
followUpSourceUpdate = null;
if (!followUpNextRequested) updateFollowUp.discardSource();
issueCapture.clearDraft();
createIssueAttachmentController.clear();
suppressCreateDraftOnHistoryClose = true;
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Queued for sync.';
if (navigator.onLine) applyOutboxResult(
await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested
);
createAndStartRequested = false;
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
throw error;
} finally {
button.disabled = false;
startButton.disabled = !createAndStart.available();
followUpButton.disabled = false;
}
}
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) pendingIssueFilingIntent =
event.submitter.id === 'create-and-start-issue' ? 'create-and-start' :
(event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign');
const intent = pendingIssueFilingIntent;
createAndStartRequested = intent === 'create-and-start';
const startCapacity = createAndStartRequested
? createAndStart.capacity(qs('#create-issue-estimate').value) : null;
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
qs('#create-issue-anyway').hidden = false;
qs('#create-issue-anyway').focus();
return;
}
if (createAndStartRequested && !createAndStart.available()) {
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
qs('#create-and-start-issue').focus();
return;
}
if (createAndStartRequested && !startCapacity.valid) {
qs('#create-issue-status').textContent = startCapacity.reason === 'required'
? 'Add a work estimate before creating and starting.'
: 'Use a whole-number estimate from 5 to 1440 minutes.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
if (createAndStartRequested && !startCapacity.fits) {
qs('#create-issue-status').textContent = 'This would put Today ' +
Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
qs('#create-issue-status').textContent = 'Preparing complete filing review…';
try {
const evidence = await createIssueAttachmentController.serialize();
const milestoneOption = qs('#create-issue-milestone').selectedOptions?.[0];
const durableDraft = {
...captureDraft,
labels: selectedIssueLabelNames(),
milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '',
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? {
completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes,
todayCapacity: startCapacity,
} : {}),
};
filingReview.open({draft: durableDraft, intent},
dFS.takeTrigger() || event.submitter || qs('#submit-new-issue'));
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#create-issue-title').focus();
}
});
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
function runFiledClaim(destination) {
const item = selectedIssue;
if (!item || !selectedIssueDetail || !filedClaimEligible(item, selectedIssueDetail)) return;
qs('#queue-filed-issue').disabled = true;
qs('#start-filed-issue').disabled = true;
const request = destination === 'queue' ?
filedAssignAndStart.run(item, { destination:'queue' }) : filedAssignAndStart.run(item);
request.catch(error => {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = 'Claim failed; this issue is still in Filed. ' + error.message + ' Retry.';
}).finally(() => {
if (selectedIssue === item && filedClaimEligible(item, selectedIssueDetail)) {
qs('#queue-filed-issue').disabled = false;
qs('#start-filed-issue').disabled = false;
}
});
}
qs('#queue-filed-issue').addEventListener('click', () => runFiledClaim('queue'));
qs('#start-filed-issue').addEventListener('click', () => runFiledClaim('start'));
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
issueController.bindTaskToggles({
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
confirmed:applyIssueContent,
restore:renderIssueBody,
});
const checklistStepManagement = issueController.bindTaskManagement({
container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'),
earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'),
fileRelated:qs('#file-checklist-step-related'), onFileRelated:startChecklistPromotion,
remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'),
status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
confirmed:applyIssueContent,
});
function closeAddChecklistStep() {
qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = '';
qs('#open-add-checklist-step').focus();
}
qs('#open-add-checklist-step').addEventListener('click', () => {
qs('#add-checklist-step-form').hidden = false;
qs('#add-checklist-step-status').textContent = 'Add one required step.';
qs('#add-checklist-step').focus();
});
qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep);
qs('#add-checklist-step-form').addEventListener('submit', async event => {
event.preventDefault();
const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline};
if (!state.item || !state.detail?.updated_at) return;
const submit = qs('#save-checklist-step');
submit.disabled = true;
qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…';
try {
const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(
issueController, state.item, state.detail, qs('#add-checklist-step').value
);
if (selectedIssue !== state.item) return;
applyIssueContent(state.item, state.detail, state.offline ? result.detail : result);
closeAddChecklistStep();
qs('#add-checklist-step-status').textContent = state.offline ?
'Checklist step queued. Pending sync.' : 'Checklist step added.';
} catch (error) {
if (selectedIssue === state.item) {
qs('#add-checklist-step-status').textContent = error.message;
qs('#add-checklist-step').focus();
}
} finally {
submit.disabled = false;
}
});
qs('#complete-checklist-issue').addEventListener('click', () => {
qs('#close-issue').click();
});
qs('#keep-checklist-issue-open').addEventListener('click', () => {
dismissedChecklistBody = selectedIssueDetail?.body;
if (selectedIssueDetail) renderChecklistCompletion(selectedIssueDetail);
qs('#issue-sheet-body').focus();
});
qs('#issue-planning').addEventListener('toggle', event => {
if (event.currentTarget.open) loadIssuePlanning();
});
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
qs('#edit-issue-content').addEventListener('click', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
const draft = issueController.loadEditDraft(selectedIssue) || {
title: selectedIssueDetail.title || '',
body: selectedIssueDetail.body || '',
expectedUpdatedAt: selectedIssueDetail.updated_at,
};
qs('#issue-edit-title').value = draft.title;
qs('#issue-edit-body').value = draft.body;
qs('#issue-edit-form').hidden = false;
if (!issueEditHistoryActive) {
history.pushState({ ...history.state, stackchainIssueEdit:true }, '', window.location.href);
issueEditHistoryActive = true;
}
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
qs('#issue-edit-title').focus();
});
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
qs(selector).addEventListener('input', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
issueController.saveEditDraft(selectedIssue, {
title: qs('#issue-edit-title').value,
body: qs('#issue-edit-body').value,
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
});
})
);
qs('#cancel-issue-content').addEventListener('click', () => {
if (issueEditHistoryActive) history.back();
else {
qs('#issue-edit-form').hidden = true;
qs('#edit-issue-content').focus();
}
});
qs('#issue-edit-form').addEventListener('submit', async event => {
event.preventDefault();
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
const title = qs('#issue-edit-title').value.trim();
const body = qs('#issue-edit-body').value.trim();
if (!title) {
qs('#issue-edit-status').textContent = 'Add a title before saving.';
qs('#issue-edit-title').focus();
return;
}
const editing = selectedIssue;
const savedDraft = issueController.loadEditDraft(editing);
const draft = {
title,
body,
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
};
const button = qs('#save-issue-content');
button.disabled = true;
qs('#issue-edit-status').textContent = 'Saving issue…';
try {
const confirmed = await issueController.updateContent(editing, draft);
applyIssueContent(editing, selectedIssueDetail, confirmed);
if (issueEditHistoryActive) history.back();
else qs('#issue-edit-form').hidden = true;
qs('#issue-sheet-status').textContent = 'Issue saved.';
qs('#edit-issue-content').focus();
} catch (error) {
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#issue-edit-title').focus();
} finally {
button.disabled = false;
}
});
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
qs('#load-older-issue-comments').addEventListener('click', async () => {
if (!issueConversation) return;
const button = qs('#load-older-issue-comments');
const panel = qs('#issue-sheet .issue-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#issue-conversation-status').textContent = 'Loading older messages…';
try {
if (issueController.readOnly(selectedIssue)) {
paintIssueConversation(await issueConversation.loadOlder(), null);
} else {
renderIssueConversation(await issueConversation.loadOlder());
}
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#save-issue-labels').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const button = qs('#save-issue-labels');
button.disabled = true;
qs('#issue-label-status').textContent = 'Saving labels…';
try {
const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds());
lastContextSnapshot = buildMyWork.replaceIssueLabels(
lastContextSnapshot, editing.repository, editing.number, confirmed.labels
);
selectedIssue = { ...editing, labels: confirmed.labels };
qs('#issue-labels').innerHTML = confirmed.labels.map(label =>
'' + escapeHtml(label) + ''
).join(' ');
paintMyWork(lastContextSnapshot);
qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.';
} catch (error) {
qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.';
} finally {
button.disabled = false;
}
});
async function saveSelectedIssueDueDate(dueDate) {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const saveButton = qs('#save-issue-due-date');
const clearButton = qs('#clear-issue-due-date');
saveButton.disabled = true;
clearButton.disabled = true;
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
try {
const confirmed = await issueController.updateDueDate(editing, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
);
selectedIssue = { ...editing, due_date: confirmed.due_date };
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
paintMyWork(lastContextSnapshot);
qs('#issue-due-status').textContent = confirmed.due_date ?
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
} catch (error) {
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
qs('#issue-due-date').focus();
} finally {
saveButton.disabled = false;
clearButton.disabled = !selectedIssueDetail?.due_date;
}
}
qs('#save-issue-due-date').addEventListener('click', () => {
const value = qs('#issue-due-date').value;
if (!value) {
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
qs('#issue-due-date').focus();
return;
}
saveSelectedIssueDueDate(value + 'T23:59:59Z');
});
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
qs('#save-issue-milestone').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const button = qs('#save-issue-milestone');
const raw = qs('#issue-milestone').value;
const milestoneId = raw ? Number(raw) : null;
button.disabled = true;
qs('#issue-milestone').disabled = true;
qs('#issue-milestone-status').textContent = milestoneId ? 'Saving milestone…' : 'Clearing milestone…';
try {
const confirmed = await issueController.updateMilestone(editing, milestoneId);
lastContextSnapshot = buildMyWork.replaceIssueMilestone(
lastContextSnapshot, editing.repository, editing.number, confirmed.milestone
);
selectedIssue = { ...editing, milestone: confirmed.milestone };
selectedIssueDetail = { ...selectedIssueDetail, milestone: confirmed.milestone };
paintMyWork(lastContextSnapshot);
qs('#issue-milestone-status').textContent = confirmed.milestone ?
'Planned for ' + confirmed.milestone.title + '. Release lane updated.' : 'Milestone cleared.';
} catch (error) {
qs('#issue-milestone-status').textContent = error.message + ' Your selection is safe; retry.';
qs('#issue-milestone').focus();
} finally {
button.disabled = false;
qs('#issue-milestone').disabled = false;
}
});
async function queueIssueScreenshotComment(item, body, operationId, advance = false) {
const message = {
kind: 'issue-comment', repository: item.repository, number: item.number, body,
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
attachment: await issueAttachmentController.serialize(),
};
if (advance) return await issueCommentNext.admit(item, message);
const admission = await authoredOutbox.enqueueDurably(message);
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
return admission;
}
async function queuePullScreenshotComment(item, body, operationId, advance = false, deliver = false) {
const message = {
kind: 'pull-comment', repository: item.repository, number: item.number, body,
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
attachment: await pullAttachmentController.serialize(),
};
if (advance) return await pullCommentNext.admit(item, message);
const admission = await authoredOutbox.enqueueDurably(message);
if (deliver) {
const delivery = await authoredOutbox.retry(admission.item.id, activeFlushLogin);
return { ...admission, delivered: delivery.confirmed?.[0] || null };
}
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
return admission;
}
async function submitCommentAndNext(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
if (!item || !workSession.checkpointed(item)) return;
const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim();
const attachmentController = kind === 'issue' ? issueAttachmentController : pullAttachmentController;
const queueScreenshot = kind === 'issue' ? queueIssueScreenshotComment : queuePullScreenshotComment;
if (!body && !attachmentController.state()) {
status.textContent = 'Write a comment before posting.';
textarea.focus();
return;
}
const postButton = qs('#send-' + kind + '-comment');
const nextButton = qs('#send-' + kind + '-comment-next');
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
item.repository + '#' + item.number + ':operation');
postButton.disabled = true;
nextButton.disabled = true;
attachmentController.setBusy(true);
status.textContent = attachmentController.state() ?
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
try {
let result;
if (attachmentController.state() && (kind === 'pull' || navigator.onLine === false)) {
result = await queueScreenshot(item, body, operationId(), true);
} else {
let preparedBody;
try {
preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) :
await pullAttachmentController.prepareComment(item, body);
} catch (error) {
if (!attachmentController.state() || !canQueueMessage(error)) throw error;
result = await queueScreenshot(item, body, operationId(), true);
}
if (!result) result = await controller.submit(item, preparedBody, operationId);
}
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
await conversationPhotoDrafts.complete(kind);
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
else status.textContent = 'Saved for next launch; background delivery unavailable.';
} catch (error) {
status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.';
textarea.focus();
} finally {
attachmentController.setBusy(false);
postButton.disabled = false;
nextButton.disabled = false;
}
}
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
if (!body && !issueAttachmentController.state()) {
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
qs('#issue-comment').focus();
return;
}
const button = qs('#send-issue-comment');
button.disabled = true;
qs('#issue-comment-status').textContent = issueAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
let preparedBody;
try {
if (issueAttachmentController.state() && navigator.onLine === false) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
button.disabled = false;
return;
}
preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body);
} catch (error) {
if (issueAttachmentController.state() && canQueueMessage(error)) {
try {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
button.disabled = false;
return;
} catch (admissionError) {
error = admissionError;
}
}
qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#issue-comment').focus();
button.disabled = false;
return;
}
try {
const comment = await issueController.comment(selectedIssue, preparedBody);
if (issueConversation) {
const conversation = issueConversation.append(comment);
if (issueController.readOnly(selectedIssue)) paintIssueConversation(conversation, null);
else renderIssueConversation(conversation);
}
qs('#issue-comment').value = '';
await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
qs('#issue-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body:preparedBody, operationId });
refreshMyWorkView();
await conversationPhotoDrafts.complete('issue');
if (admission.background) {
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
qs('#issue-comment').focus();
}
} else {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
}
} finally {
button.disabled = false;
}
});
qs('#release-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
const releasing = selectedIssue;
const continuingSession = workSession.checkpointed(releasing);
const button = qs('#release-issue');
button.disabled = true;
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
try {
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, releasing.repository, releasing.number
);
closeIssueSheet();
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(releasing);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = releasing.key + ' released. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = releasing.key + ' released. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = releasing.key + ' released, but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#load-issue-handoff').addEventListener('click', async () => {
if (!selectedIssue) return;
const button = qs('#load-issue-handoff');
const select = qs('#issue-handoff-recipient');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
try {
const candidates = await issueController.loadHandoffCandidates(selectedIssue);
const currentAssignees = new Set(selectedIssue.is_filed ? (selectedIssue.assignees || []) : []);
const availableCandidates = candidates.filter(candidate => !currentAssignees.has(candidate.login));
select.textContent = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = availableCandidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
availableCandidates.forEach(candidate => {
const option = document.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
select.disabled = !availableCandidates.length;
qs('#confirm-issue-handoff').disabled = true;
qs('#issue-handoff-status').textContent = availableCandidates.length ?
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
if (availableCandidates.length) select.focus();
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
button.disabled = false;
button.focus();
}
});
qs('#issue-handoff-recipient').addEventListener('change', event => {
qs('#confirm-issue-handoff').disabled = !event.target.value;
});
qs('#confirm-issue-handoff').addEventListener('click', async () => {
const recipient = qs('#issue-handoff-recipient').value;
if (!selectedIssue || !recipient) return;
const changingDelegate = selectedIssue.is_filed && !selectedIssue.is_completed &&
Array.isArray(selectedIssue.assignees) && selectedIssue.assignees.length > 0;
const previousDelegates = changingDelegate ? selectedIssue.assignees.join(', @') : '';
const confirmation = changingDelegate ?
'Change delegate from @' + previousDelegates + ' to @' + recipient + ' for ' + selectedIssue.key + '?' :
'Hand off ' + selectedIssue.key + ' to @' + recipient + '?';
if (!window.confirm(confirmation)) return;
const handingOff = selectedIssue;
const continuingSession = workSession.checkpointed(handingOff);
const button = qs('#confirm-issue-handoff');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
try {
if (changingDelegate) {
const confirmed = await issueController.reassign(selectedIssue, recipient);
Object.assign(handingOff, { assignees:confirmed.assignees, updated_at:confirmed.updated_at || handingOff.updated_at });
selectedIssue = handingOff;
if (selectedIssueDetail) selectedIssueDetail.assignees = confirmed.assignees;
qs('#issue-assignees').textContent = 'Assigned to ' + recipient;
qs('#issue-handoff').open = false;
qs('#issue-handoff-status').textContent = 'Delegate changed to @' + recipient + '.';
refreshMyWorkView({ reconcileSession:false });
return;
}
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, handingOff.repository, handingOff.number
);
closeIssueSheet();
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(handingOff);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + ', but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
}
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue) return;
const withdrawing = selectedIssue.is_filed && !selectedIssue.is_assigned;
const confirmation = withdrawing ?
'Withdraw ' + selectedIssue.key + '? This closes the delegated request.' :
'Close ' + selectedIssue.key + '?';
if (!window.confirm(confirmation)) return;
const closing = selectedIssue;
const button = qs('#close-issue');
button.disabled = true;
if (selectedIssueOffline) {
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
try {
const outcome = await closeOfflineIssue(closing);
closeIssueSheet();
refreshMyWorkView({ reconcileSession:false });
if (!outcome.advanced) {
qs('#my-work-action-status').textContent = 'Issue close queued; Today still needs completion.';
} else if (outcome.admission.background) {
qs('#my-work-action-status').textContent = 'Issue close queued. Next Today item opened.';
} else {
qs('#my-work-action-status').textContent = 'Issue close saved. Next Today item opened.';
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
button.disabled = false;
button.focus();
}
return;
}
qs('#issue-sheet-status').textContent = 'Closing issue…';
try {
const result = await issueController.close(selectedIssue);
closeIssueSheet();
if (withdrawing) Object.assign(closing, {
state:'closed',is_completed:true,updated_at:result.updated_at || closing.updated_at,
});
else lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number));
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = closing.key + (withdrawing ? ' withdrawn.' : ' closed.');
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#pull-review').addEventListener('toggle', event => {
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#refresh-pull-checks').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const button = qs('#refresh-pull-checks');
button.disabled = true;
qs('#pull-checks-summary').textContent = 'Refreshing…';
try {
const status = await pullController.loadChecks(item);
if (selectedPull !== item) return;
renderPullCheckStatus(status);
} catch (_error) {
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-pull-file').addEventListener('click', () => {
createPullSheet.focusNextUnreviewed(document, selectedPullDetail, pullReviewState, pullController);
});
qs('#load-older-pull-comments').addEventListener('click', async () => {
if (!pullConversation) return;
const button = qs('#load-older-pull-comments');
const panel = qs('#pull-sheet .pull-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#pull-conversation-status').textContent = 'Loading older messages…';
try {
renderPullConversation(await pullConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
qs('#send-pull-comment').addEventListener('click', async () => {
if (!selectedPull) return;
const item = selectedPull;
const body = qs('#pull-comment').value.trim();
if (!body && !pullAttachmentController.state()) {
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
qs('#pull-comment').focus();
return;
}
const button = qs('#send-pull-comment');
button.disabled = true;
pullAttachmentController.setBusy(true);
qs('#pull-comment-status').textContent = pullAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' +
item.repository + '#' + item.number + ':operation');
let preparedBody;
try {
if (pullAttachmentController.state()) {
const admission = await queuePullScreenshotComment(item, body, operationId, false, true);
refreshMyWorkView();
pullController.saveDraft(item, '');
if (selectedPull === item) {
if (admission.delivered && pullConversation) {
renderPullConversation(pullConversation.append(admission.delivered));
}
qs('#pull-comment').value = '';
await conversationPhotoDrafts.complete('pull');
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
(admission.background ? 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.');
}
return;
}
preparedBody = body;
const comment = await pullController.comment(item, preparedBody);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
await conversationPhotoDrafts.complete('pull');
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error) && !pullAttachmentController.state()) {
try {
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({
kind:'pull-comment', repository:item.repository, number:item.number,
body:preparedBody ?? body, operationId,
});
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
refreshMyWorkView();
qs('#pull-comment-status').textContent = admission.background ?
'Queued for sync when the connection returns.' :
'Saved for next launch; background delivery unavailable.';
return;
} catch (admissionError) {
error = admissionError;
}
}
qs('#pull-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#pull-comment').focus();
} finally {
pullAttachmentController.setBusy(false);
button.disabled = false;
}
});
qs('#merge-pull').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
const merging = selectedPull;
const button = qs('#merge-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
rR.capture(merging, mergeResult);
closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = merging.key + ' merged.';
}
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
button.disabled = false;
button.focus();
}
});
const updateDecisionActions = createUpdateDecisionActions({
transaction:updateDecision, triage:updateTriage, close:()=>closeUpdateSheet(true),
reader:notificationReader, items:()=>lastMyWork, undo:notificationUndo,
keepControl:qs('#keep-update-unread'), readControl:qs('#mark-update-read-next'),
});
const keepUpdateUnread = () => updateDecisionActions.keepUnread();
const markUpdateRead = () => updateDecisionActions.markRead();
qs('#keep-update-unread').addEventListener('click', keepUpdateUnread);
createUpdateTriageGesture({
surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail,
keepUnread: keepUpdateUnread, markRead: markUpdateRead,
});
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);
});
qs('#retry-update-conversation').addEventListener('click', () => {
notificationReader.retryConversation();
});
qs('#load-older-update-comments').addEventListener('click', async () => {
const button = qs('#load-older-update-comments');
const panel = qs('#update-sheet .update-sheet-panel');
const previousHeight = panel.scrollHeight;
const previousTop = panel.scrollTop;
button.disabled = true;
await notificationReader.loadOlder();
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
button.disabled = false;
});
qs('#update-reply').addEventListener('input', event => {
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
});
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
updateReplyAttachmentController.setBusy(true);
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
qs('#send-update-reply').disabled = false;
updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
await conversationPhotoDrafts.complete('update');
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
await conversationPhotoDrafts.complete('update');
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
}
});
qs('#send-update-reply-read-next').addEventListener('click', async () => {
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
const button = qs('#send-update-reply-read-next');
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
updateReplyAttachmentController.setBusy(true);
qs('#update-reply-status').textContent = 'Replying, then marking read…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
if (result?.accepted) await conversationPhotoDrafts.complete('update');
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
if (result?.delivery === 'posted' && result?.next) {
notificationUndo.offer(item, result.next.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
} finally {
button.disabled = false;
sendButton.disabled = false;
updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', markUpdateRead);
qs('#acknowledge-update-next').addEventListener('click', async () => {
const button = qs('#acknowledge-update-next');
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) {
notificationUndo.offer(result.item, result.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#mute-update-next').addEventListener('click', async () => {
if (!selectedUpdate || offlineWorkMode) return;
const button = qs('#mute-update-next');
const item = selectedUpdate;
button.disabled = true;
qs('#update-sheet-status').textContent = 'Muting future updates…';
try {
await muteNotification(item.notification_id);
const result = await notificationReader.acceptReadAndNext(lastMyWork, item);
if (result && updateTriage.active()) updateTriage.acceptCompleted();
} catch (error) {
qs('#update-sheet-status').textContent = error.muted ?
'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message;
if (error.muted) {
button.hidden = true;
qs('#mark-update-read-next').focus();
}
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
const restored = await notificationUndo.run();
button.disabled = restored;
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
});
qs('#refresh-review-checks').addEventListener('click', async () => {
if (!selectedReview || offlineReview) return;
const item = selectedReview;
const button = qs('#refresh-review-checks');
button.disabled = true;
qs('#review-checks-summary').textContent = 'Refreshing…';
try {
const detail = await reviewController.loadChecks(item);
if (selectedReview !== item) return;
if (selectedReviewHead && detail.head_sha !== selectedReviewHead) {
qs('#review-sheet-status').textContent = 'New commits detected. Reload the review before submitting feedback.';
qs('#submit-review').disabled = true;
}
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail);
} catch (error) {
if (selectedReview === item) qs('#review-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-review').addEventListener('click', () => {
if (progress) openNextUnreviewed(progress.snapshot());
});
qs('#review-wrap-lines').addEventListener('click', () => {
const next = !wrapPreference.snapshot().wrapped;
applyReviewWrap(wrapPreference.setWrapped(next));
});
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#save-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
const body = qs('#review-inline-body').value.trim();
if (!body) {
qs('#review-inline-body').focus();
return;
}
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
activeInlineTarget.classList.add('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#delete-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
activeInlineTarget.classList.remove('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#cancel-inline-comment').addEventListener('click', () => {
const target = activeInlineTarget;
closeInlineComposer();
target?.focus();
});
qs('#submit-review').addEventListener('click', async () => {
if (!draft || !selectedReview || !selectedReviewHead) return;
const snapshot = draft.snapshot();
const labels = { approve: 'Approve', request_changes: 'Request changes' };
if (labels[snapshot.decision] && !window.confirm(
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
)) return;
const button = qs('#submit-review');
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) {
const queuedTodayReview = reviewingActiveTodayItem();
qs('#review-submit-status').textContent = 'Queueing review safely…';
try {
await authoredOutbox.enqueueDurably({
kind: 'pull-review',
repository: selectedReview.repository,
number: selectedReview.number,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
decision: snapshot.decision,
expectedHeadSha: selectedReviewHead,
comments: snapshot.comments,
draftKey: draft.storageKey,
progressKey: progress?.storageKey || '',
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
});
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
if (queuedTodayReview) {
const advanced = completeTodayItem(selectedReview, {
successMessage: 'Review queued. Next Today item opened.',
failureMessage: 'Review queued, but Today still needs completion.',
});
if (!advanced) {
qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.';
}
} else {
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
button.disabled = false;
button.focus();
}
return;
}
qs('#review-submit-status').textContent = 'Submitting review…';
try {
const item = selectedReview;
const progressSnapshot = progress?.snapshot() || { reviewed: [] };
const result = await reviewController.submit(selectedReview, {
decision: snapshot.decision,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
expected_head_sha: selectedReviewHead,
comments: snapshot.comments,
});
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
const canContinueToMerge = createReviewController.prepareMergeContinuation({
storage: localStorage,
item,
headSha: selectedReviewHead,
reviewed: progressSnapshot.reviewed,
decision: snapshot.decision,
});
draft.clear();
progress?.clear();
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
if (progress) showReviewProgress(progress.snapshot());
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
if (canContinueToMerge) {
qs('#continue-review-to-merge').hidden = false;
qs('#continue-review-to-merge').focus();
} else {
await load();
if (workSession.active()) await runTodayTransition('complete');
else button.focus();
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
button.disabled = false;
button.focus();
}
});
qs('#continue-review-to-merge').addEventListener('click', () => {
const item = selectedReview;
if (!item) return;
workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true });
});
qs('#copy-review-feedback').addEventListener('click', async () => {
if (!draft || !selectedReview || reviewHandoffPending) return;
reviewHandoffPending = true;
qs('#copy-review-feedback').disabled = true;
const fallback = qs('#review-copy-fallback');
const directLink = qs('#review-handoff-link');
fallback.hidden = true;
directLink.hidden = true;
let handoffWindow = null;
try {
const result = await createReviewController.copyAndContinue({
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
url: selectedReview.url,
copy: text => navigator.clipboard.writeText(text),
open: () => {
handoffWindow = window.open('about:blank', '_blank');
if (handoffWindow) handoffWindow.opener = null;
return handoffWindow;
},
fallback: text => {
fallback.value = text;
fallback.hidden = false;
fallback.focus();
fallback.select();
},
});
if (result.opened) {
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
} else {
directLink.hidden = false;
qs('#review-handoff-status').textContent = result.copied ?
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
}
} finally {
reviewHandoffPending = false;
qs('#copy-review-feedback').disabled = false;
}
});
const contextPoller = createContextPoller({
fetchContext: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot,
onError: async error => {
await handleContextError(error);
setEventStreamStatus('Update failed · showing last activity');
},
isHidden: () => document.hidden,
intervalMs: 8000,
});
function load() { return contextPoller.refresh({ force: true }); }
createMobilePullRefresh({refresh:load}).start();
const liveDataStatusSheet = qs('#live-data-status-sheet');
const liveDataStatusTrigger = qs('#open-live-data-status');
let latestLiveFreshness = {};
function feedAge(feed) {
return feed.ageSeconds === null ? 'Snapshot age unavailable' :
(feed.ageSeconds < 60 ? feed.ageSeconds + 's old' : Math.floor(feed.ageSeconds / 60) + 'm old');
}
function renderLiveDataStatus(freshness = latestLiveFreshness) {
latestLiveFreshness = freshness;
const description = liveDataStatus.describe(freshness);
setStatus(description.summary);
qs('#live-data-status-feeds').innerHTML = description.feeds.map(feed =>
'' + escapeHtml(feed.label) + '' +
escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) :
feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '
'
).join('');
const pollState = contextPoller.getState();
const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ?
Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null);
qs('#live-data-status-retry').textContent = retrySeconds ?
'Next automatic retry in ' + retrySeconds + 's.' :
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
}
liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
const liveDataRefresh = liveDataStatus.createRefreshController({
button: qs('#refresh-live-data'),
output: qs('#live-data-status-result'),
refresh: () => contextPoller.refresh({ force: true }),
onState: state => { if (state === 'refreshing') setStatus('Refreshing live data'); },
});
qs('#refresh-live-data').addEventListener('click', liveDataRefresh.run);
const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status');
const retryOfflineStorage = qs('#retry-offline-storage');
const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status');
async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled();
keepWorkOffline.disabled = !offlineStorageReady;
retryOfflineStorage.hidden = offlineStorageReady;
const saved = await offlineWorkStore.load();
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
}
async function updateDeliveryReceiptControls(message) {
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
if (!supported) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
return;
}
if (!confirmedOwnerLogin) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
return;
}
deliveryReceipts.checked = Notification.permission === 'granted' &&
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
}
function setOfflineWorkMode(value) {
offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
}
async function hydrateOfflineWork(mode = 'offline') {
const saved = await offlineWorkStore.load();
if (!saved) return false;
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin;
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
interruptionPrompt.restore();
updatePlanningAvailability();
syncPendingTomorrow();
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
lastNotifications = saved.notifications;
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {};
lastContextSnapshot = saved;
hasContextSnapshot = true;
paintMyWork(saved);
setOfflineWorkMode(true);
const savedLabel = fmt(saved.saved_at);
if (outage) {
qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.';
offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.';
setStatus('Outage · saved snapshot');
} else {
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
setStatus('Offline · saved snapshot');
}
offlineStatus.hidden = false;
return true;
}
async function showOfflineStatus() {
activeFlushLogin = '';
offlineStatus.hidden = false;
setStatus('Offline');
if (!hasContextSnapshot) await hydrateOfflineWork();
}
const reconnectOutboxes = createReconnectOutboxes({
refresh: async () => {
setStatus('Reconnecting…');
const snapshot = await contextPoller.refresh({ force: true });
if (!snapshot) {
offlineStatus.hidden = false;
setOfflineWorkMode(true);
return null;
}
offlineStatus.hidden = true;
setOfflineWorkMode(false);
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
return snapshot;
},
restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; },
flushIssue: flushIssueOutbox,
flushAuthored: flushAuthoredOutbox,
flushNotificationReads: flushNotificationReadOutbox,
});
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
window.addEventListener('online', reconnectAfterOnline);
workspaceLifecycle.replayOnline(reconnectAfterOnline);
async function setOfflineWorkEnabled(enabled) {
keepWorkOffline.checked = enabled;
offlineWorkStore.setEnabled(enabled);
if (enabled && liveMode && lastContextSnapshot) {
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination });
if (admitted === false) {
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
return false;
}
}
if (!enabled) await offlineWorkStore.clear();
await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (enabled) warmTodayOffline();
else offlineToday.cancel();
return offlineWorkStore.enabled();
}
keepWorkOffline.addEventListener('change', async () => {
await setOfflineWorkEnabled(keepWorkOffline.checked);
});
deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked;
if (enabled && Notification.permission !== 'granted') {
enabled = await Notification.requestPermission() === 'granted';
}
deliveryReceipts.checked = enabled;
if (backgroundIssueSync && confirmedOwnerLogin) {
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
}
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
});
qs('#clear-offline-work').addEventListener('click', async () => {
await offlineWorkStore.clear();
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
await updateOfflineWorkControls('Offline work data cleared.');
});
retryOfflineStorage.addEventListener('click', async () => {
retryOfflineStorage.disabled = true;
offlineWorkStatus.textContent = 'Retrying offline saving…';
offlineStorageReady = await offlineWorkStore.retry();
retryOfflineStorage.disabled = false;
if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination });
}
await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
'Offline saving unavailable · online work remains live.');
});
qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
);
await updateOfflineWorkControls();
updateDeliveryReceiptControls();
if (!navigator.onLine) await showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
qs('#refresh').addEventListener('click', load);
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
qs('#plan-tomorrow').addEventListener('click', event => openTomorrowPlanner(event.currentTarget));
qs('#keep-phone-tomorrow').addEventListener('click', async () => {
const keep = qs('#keep-phone-tomorrow');
const use = qs('#use-server-tomorrow');
keep.disabled = true;
use.disabled = true;
qs('#tomorrow-conflict-status').textContent = 'Saving this phone’s plan…';
try {
const saved = await tomorrowPlan.keepLocal();
renderTomorrowQueueSummary(saved);
qs('#my-work-action-status').textContent = 'This phone’s Tomorrow plan is saved to your account. Today was not changed.';
closePlanToday();
} catch (error) {
const conflict = tomorrowPlan.conflict();
if (conflict) showTomorrowConflict(conflict);
qs('#tomorrow-conflict-status').textContent = error?.status === 409 ?
'Tomorrow changed again. Both latest plans are still preserved; choose again.' :
`${error.message || 'Tomorrow could not be saved.'} Both plans are still preserved.`;
} finally {
keep.disabled = false;
use.disabled = false;
}
});
qs('#use-server-tomorrow').addEventListener('click', () => {
const adopted = tomorrowPlan.useRemote();
if (!adopted) return;
renderTomorrowQueueSummary(adopted);
qs('#my-work-action-status').textContent = 'Saved account Tomorrow plan selected. Today was not changed.';
closePlanToday();
});
qs('#keep-phone-week').addEventListener('click', async () => {
const keep=qs('#keep-phone-week'),use=qs('#use-server-week');
keep.disabled=true;use.disabled=true;
qs('#week-conflict-status').textContent='Saving this phone’s complete week…';
try {
await weekPlan.keepLocal();
qs('#mobile-week-summary').textContent=weekPlan.summary();
qs('#my-work-action-status').textContent='This phone’s Week Ahead plan is saved. Today was not changed.';
closePlanToday();
} catch(error) {
const conflict=weekPlan.conflict();
if(conflict)showWeekConflict(conflict);
qs('#week-conflict-status').textContent=error?.status===409?
'Week Ahead changed again. Both latest weeks are preserved; choose again.':
`${error.message||'Week Ahead could not be saved.'} Both weeks are still preserved.`;
} finally {keep.disabled=false;use.disabled=false;}
});
qs('#use-server-week').addEventListener('click', () => {
const adopted=weekPlan.useRemote();
if(!adopted)return;
qs('#mobile-week-summary').textContent=weekPlan.summary();
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
closePlanToday();
});
qs('#save-merged-week').addEventListener('click', async () => {
const save=qs('#save-merged-week');
save.disabled=true;
qs('#week-conflict-status').textContent='Saving combined Week Ahead plan…';
try {
await weekPlan.saveMerged();
qs('#mobile-week-summary').textContent=weekPlan.summary();
qs('#my-work-action-status').textContent='Combined Week Ahead changes are saved. Today was not changed.';
closePlanToday();
} catch(error) {
const conflict=weekPlan.conflict();
if(conflict)showWeekConflict(conflict);
qs('#week-conflict-status').textContent=error?.status===409?
'Week Ahead changed again. The latest days are preserved; review the remaining choices.':
`${error.message||'The combined week could not be saved.'} Your phone plan is still preserved.`;
} finally {
const conflict=weekPlan.conflict();
save.disabled=Boolean(conflict?.conflicts?.some(item=>!item.choice));
}
});
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
qs('#confirm-week-plan').addEventListener('click', () => {
if(!weekFlow.confirm()){
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
return;
}
wc.open(weekPlan.state());
});
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
});
qs('#today-readiness-close').addEventListener('click', () => closeTodayReadiness());
qs('#today-readiness-sheet').addEventListener('click', event => {
if (event.target === qs('#today-readiness-sheet')) closeTodayReadiness();
});
qs('#today-readiness-next').addEventListener('click', () => todayReadiness.startNextReady());
qs('#today-readiness-anyway').addEventListener('click', () => todayReadiness.workAnyway());
qs('#today-readiness-retry').addEventListener('click', () => todayReadiness.retry());
qs('#today-readiness-plan').addEventListener('click', () => {
closeTodayReadiness();
openPlanToday(qs('#plan-today'));
});
qs('#plan-today-available').addEventListener('change', event => {
planToday.setCapacity(Number(event.currentTarget.value));
qs('#plan-today-error').textContent = '';
renderPlanToday();
});
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
function commitPlanToday(start, button) {
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
if (result === 'saved') {
button.dataset.confirmOverCapacity = '';
if (!weekFlow.active() || !weekFlow.advance()) taskOverlayHistory.leave();
} else if (result === 'confirm-over-capacity') {
button.dataset.confirmOverCapacity = 'true';
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
button.textContent = start ? 'Save over capacity & start' : 'Save over capacity';
} else {
qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
}
}
qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget));
qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget));
qs('#start-work-session').addEventListener('click', () => {
const sessionItems = workSession.items();
if (!sessionItems.length) {
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
if (selectedWorkFilter === 'today') runTodayTransition('start');
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
else workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);
document.querySelectorAll('[data-work-session-next]').forEach(button =>
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
);
document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button =>
button.addEventListener('click', () => timerView.toggle())
);
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
button.addEventListener('click', () =>
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
)
);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#load-more-work').addEventListener('click', async () => {
if (selectedWorkFilter === 'agenda') {
await completeAgendaIssues();
return;
}
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
if (!stream || !lastContextSnapshot) return;
const button = qs('#load-more-work');
button.disabled = true;
const existing = stream === 'issue' || stream === 'filed' ?
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
try {
const loaded = await workPager.loadMore(stream, existing);
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
else button.focus();
} finally {
button.disabled = false;
}
});
qs('#select-work').addEventListener('click', () => {
if (notificationSelection.snapshot().active) notificationSelection.cancel();
workSelection.start();
document.querySelector('[data-select-work-id]')?.focus();
});
qs('#cancel-work-selection').addEventListener('click', () => {
workSelection.cancel();
qs('#select-work').focus();
});
qs('#batch-add-today').addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const result = todayWork.addMany(selectedItems);
if (result.status === 'added') {
result.ids.forEach(id => todaySync.enqueue('add', id));
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
workSelection.cancel();
} else {
qs('#my-work-action-status').textContent = result.status === 'full' ?
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
(result.status === 'exists' ? 'Every selected item is already in Today.' :
'Could not save Today on this device. Nothing was added; selection unchanged.');
}
refreshMyWorkView();
});
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const until = laterWork.presetUntil(button.dataset.batchDefer);
const result = laterWork.deferMany(selectedItems, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') workSelection.cancel();
refreshMyWorkView();
}));
qs('#select-updates').addEventListener('click', () => {
notificationSelection.start();
qs('#update-selection-status').textContent = '0 of 50 selected';
document.querySelector('[data-select-notification-id]')?.focus();
});
qs('#cancel-update-selection').addEventListener('click', () => {
notificationSelection.cancel();
qs('#select-updates').focus();
});
document.querySelectorAll('[data-defer-selected]').forEach(button => button.addEventListener('click', () => {
const selection = notificationSelection.snapshot();
const ids = new Set(selection.ids);
const selectedUpdates = lastMyWork.filter(item => item.has_update && ids.has(item.notification_id));
const until = laterWork.presetUntil(button.dataset.deferSelected);
const result = laterWork.deferMany(selectedUpdates, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selection.count + ' updates deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') notificationSelection.cancel();
refreshMyWorkView();
}));
qs('#bulk-mark-read').addEventListener('click', async () => {
const selection = notificationSelection.snapshot();
if (!selection.ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
renderMyWork();
return;
}
bulkConfirmationPending = false;
bulkMarkPending = true;
renderMyWork();
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
if (result) {
const marked = new Set(result.marked);
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
notificationSelection.retain(result.failed);
if (!result.failed.length) notificationSelection.cancel();
}
bulkMarkPending = false;
renderMyWork();
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
});
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
selectWorkQueue(button.dataset.workFilter);
});
});
function updateQueueFinder(matches, loaded, incomplete) {
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
qs('#queue-find-label').textContent = queueLabel;
qs('#clear-queue-find').hidden = !queueFindQuery;
qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
qs('#queue-find-status').textContent = queueFindQuery ?
(matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
(incomplete ? ' among loaded work.' : '.')) : '';
}
qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
qs('#queue-find-input').addEventListener('input', event => {
queueFindQuery = event.target.value;
renderMyWork();
});
qs('#clear-queue-find').addEventListener('click', () => {
queueFindQuery = '';
qs('#queue-find-input').value = '';
renderMyWork();
qs('#queue-find-input').focus();
});
qs('#search-older-work').addEventListener('click', () => {
const notificationButton = qs('#load-more-notifications');
const workButton = qs('#load-more-work');
const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
if (!target || target.hidden || target.disabled) return;
qs('#queue-find-status').textContent = 'Searching older ' +
(selectedWorkFilter === 'update' ? 'updates…' : 'work…');
target.click();
});
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
const button = qs('[data-work-filter="' + filter + '"]');
if (!button) return false;
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
selectedWorkFilter = filter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
if (workSelection.snapshot().active) workSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {}
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item === button))
);
const selectedCount = button.querySelector('[data-work-count]').textContent;
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
renderMyWork();
updateWorkSessionActions();
updateWorkPaginationControls();
if (selectedWorkFilter === 'agenda') completeAgendaIssues();
if (!preserveRoute) workRoute.queue(filter);
return true;
}
async function openWorkQueueRoute(filter, action = null) {
if (filter === 'following') return followingQueue.route();
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
if (action !== 'protect-today') return;
await completeAgendaIssues();
if (workPagination.issue?.has_more) {
qs('#my-work-action-status').textContent =
'Protect Today needs all assigned deadlines. Retry when connected.';
return;
}
const proposal = protectToday.propose({
agenda:agendaMyWork(activeMyWork), today:todayMyWork,
identity:item => todayWork.identity(item), limit:todayWork.limit,
});
if (!proposal.protected.length) {
qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.';
return;
}
pendingProtectToday = proposal;
openPlanToday(qs('#protect-today'));
}
qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value;
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
catch (e) {}
renderMyWork();
if (workSession.active()) workSession.reconcile();
});
let pushController = null;
for (const selector of [
qs('#push-deadline-hour'), qs('#device-setup-deadline-hour'),
qs('#push-start-day-hour'), qs('#device-setup-start-day-hour'),
]) {
for (let hour = 0; hour < 24; hour += 1) {
const option = document.createElement('option');
option.value = String(hour);
option.textContent = `${String(hour).padStart(2, '0')}:00`;
selector.appendChild(option);
}
selector.value = '9';
}
qs('#push-deadline-hour').addEventListener('change', event => {
qs('#device-setup-deadline-hour').value = event.target.value;
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
});
qs('#device-setup-deadline-hour').addEventListener('change', event => {
qs('#push-deadline-hour').value = event.target.value;
});
qs('#push-deadline-days').addEventListener('change', event => {
qs('#device-setup-deadline-days').value = event.target.value;
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
});
qs('#device-setup-deadline-days').addEventListener('change', event => {
qs('#push-deadline-days').value = event.target.value;
});
qs('#push-start-day-hour').addEventListener('change', event => {
qs('#device-setup-start-day-hour').value = event.target.value;
if (qs('#push-start-day').checked) pushController?.changeStartDay();
});
qs('#device-setup-start-day-hour').addEventListener('change', event => {
qs('#push-start-day-hour').value = event.target.value;
});
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) {
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
await issueCaptureFeatures.load('push-notifications');
const controller = createPushNotifications({
control:qs('#push-updates'),
status:qs('#push-update-status'),
testControl:qs('#push-test'),
deadlineControl:qs('#push-deadlines'),
deadlineStatus:qs('#push-deadline-status'),
deadlineHour:qs('#push-deadline-hour'),
deadlineDays:qs('#push-deadline-days'),
startDayControl:qs('#push-start-day'),
startDayStatus:qs('#push-start-day-status'),
startDayHour:qs('#push-start-day-hour'),
followingControl:qs('#push-following'),
followingStatus:qs('#push-following-status'),
humanGateControl:qs('#push-human-gates'),
humanGateStatus:qs('#push-human-gates-status'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
onReviewDeadlines:() => {
window.location.hash = '#/my-work/agenda';
setTimeout(() => {
const protect = qs('#protect-today');
if (!protect.closest('[hidden]')) protect.click();
}, 0);
},
notification:window.Notification,
serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson,
});
await controller.init();
pushController = controller;
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
qs('#device-setup-start-day-hour').value = qs('#push-start-day-hour').value;
return controller;
}).catch(error => {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
return null;
});
} else {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
}
qs('#device-setup-start-day').addEventListener('click', async () => {
const controller = await pushControllerReady;
if (!controller) return;
qs('#push-start-day-hour').value = qs('#device-setup-start-day-hour').value;
qs('#push-start-day').checked = true;
await controller.changeStartDay();
qs('#device-setup-start-day-status').textContent = qs('#push-start-day-status').textContent;
});
let deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
deferredInstallPrompt = event;
});
let deviceSetup = null;
async function ensureDeviceSetup() {
if (deviceSetup) return deviceSetup;
await issueCaptureFeatures.load('device-setup');
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const installApp = createInstallApp({
window, initialPrompt:deferredInstallPrompt,
card:qs('#install-app-card'), installButton:qs('#install-app'),
dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'),
status:qs('#install-app-status'), storage:localStorage,
isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true,
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
});
installApp.start();
const deviceStorage = createDeviceStorage.mount(document);
await deviceStorage.start();
deviceSetup = createMobileDeviceSetup.mount({
document, installApp, promptStorage:localStorage,
timerView,
offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true),
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
protectStorage:() => deviceStorage.requestPersistence(),
notificationReadiness:() => pushController?.notificationReadiness()
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
appBadgeReadiness:() => appBadge.readiness(),
enableAppBadge:() => appBadge.enable(),
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
if (controller.notificationReadiness().state === 'blocked') {
await controller.recoverPermission('updates');
return;
}
qs('#push-updates').checked = true;
await controller.change();
},
deadlineReadiness:() => pushController?.deadlineReadiness()
|| {state:'unavailable', detail:'Deadline reminders are unavailable.'},
enableDeadline:async () => {
const controller = await pushControllerReady;
if (!controller) return;
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
if (controller.deadlineReadiness().state === 'blocked') {
await controller.recoverPermission('deadline');
return;
}
await controller.enableDeadline();
},
});
await deviceSetup.start();
return deviceSetup;
}
qs('#open-device-setup').addEventListener('click', async event => {
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
});
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);
if (!adoptedProgressiveSnapshot && progressiveWorkHandoff?.liveSnapshotPromise) {
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
}
if (!adoptedProgressiveSnapshot) await load();
if (progressiveWorkHandoff?.openWork) {
const progressiveItem = lastMyWork.find(item =>
item.repository === progressiveWorkHandoff.openWork.repository &&
Number(item.number) === Number(progressiveWorkHandoff.openWork.number));
if (progressiveItem) {
openRoutedWork(progressiveItem, null);
qs('#progressive-work-detail').hidden = true;
}
}
await appShortcut.run();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) deviceSetup?.render();
});
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();