(async function(){ const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window })); await workspaceLifecycle.optionalReady; const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.(); const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.(); 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 = {}; 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); mobileStartDay.render(); }, onStatus:status => { if (status === 'loading') return; queueCounts.followingUnavailable = status === 'error'; 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(), 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(), }); 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(); 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 humanGates = createHumanGates({ storage:localStorage, getLogin:()=>planningOwnerLogin, getAccountKey:()=>planningOwnerAccountKey, isOnline:()=>navigator.onLine, location:window.location, fetchJson:fetchReviewJson, 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'), }, }); const openHumanGates = () => humanGates.open().catch(error => { qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.'; }); 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-gate-detail').addEventListener('click', event => { const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision; if (!decision) return; const detail = qs('#human-gate-detail'); const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked])); humanGates.decideAndNext(decision, { checklist, reason:detail.querySelector('[data-gate-reason]')?.value || '', override_reason:detail.querySelector('[data-gate-override]')?.value || '', }).catch(error => { qs('#human-gates-status').textContent = error.message; }); }); humanGates.load().catch(() => {}); if (window.location.hash === '#/my-work/human-gates') 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 '
' + key + '' + escapeHtml(item?.title || 'Untitled work') + '
'; }).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) => '
' + escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') + 'State: ' + escapeHtml(blocker.state || 'open') + '
' ).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 => '
    #' + i.number + ' ' + escapeHtml(i.title) + '' + '
    ' + (i.labels || []).map(l => '' + escapeHtml(String(l)) + '').join(' ') + '
    ').join('') : '
    No open issues.
    '); const prsBox = qs('#prs-content'); const openPrs = (data.pull_requests || []).slice(0, 12); prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '
    #' + p.number + ' ' + escapeHtml(p.title) + '' + '
    ' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '
    ').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; preparationItems = { delivery:draftInbox.partition(lastDrafts).deliveries, 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; 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 : '
    ' + 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 : ''; 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; 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'), 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); })();