(async function(){ const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); 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('#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('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true }, ], }); mobileComposerViewport.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 (e) { console.warn('Could not persist panel state', e); } }); }); 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 openMobileWorkFallback() { qs('[data-work-filter="all"]').click(); qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } function openMobileQueue(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(); } const mobileWorkEntry = createMobileWorkEntry({ isTodayActive: () => workSession.checkpointed(), isTodayResumable: () => workSession.resumable(), getTodayCount: () => todayMyWork.length, getEligibleCount: () => activeMyWork.length, continueToday: continueTodaySession, resumeToday: resumeTodaySession, startToday: startTodaySession, planToday: () => openPlanToday(mobileTaskButtons.work), openFallback: openMobileWorkFallback, }); 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'), onSelectQueue: openMobileQueue, overlays: mobileTaskOverlays, actions: { work: () => mobileWorkEntry.open(), find: () => qs('#find-work').click(), new: () => qs('#new-issue').click(), search: () => qs('#open-palette').click(), queues: () => {}, }, observe(callback, overlays) { const observer = new MutationObserver(callback); overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']})); return observer; }, }); mobileTaskDock.start(); let liveMode = true; let offlineWorkMode = false; const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; let selectedWorkFilter = 'all'; let selectedWorkMilestone = 'all'; let savedWorkFilter = null; let launchFilterResolved = false; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); if (['all', 'today', 'attention', '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) { console.warn('Could not restore My Work filter', e); } let lastMyWork = []; let lastDrafts = []; let lastNotifications = []; let lastContextSnapshot = null; let notificationPagination = { page: 1, total: 0, has_more: false }; let workPagination = {}; let hasContextSnapshot = false; let selectedReview = null; let reviewTrigger = null; let offlineReview = false; let selectedUpdate = null; let updateTrigger = null; let selectedIssue = null; let selectedIssueOffline = false; let selectedIssueDetail = null; let issueBlockerCandidates = []; let issueBlockerSearchTimer = null; let issueConversation = null; let issueTrigger = null; let selectedPull = null; let pullTrigger = null; let selectedPullDetail = null; let pullConversation = null; let pullReviewState = null; let creatingIssue = false; let createAndStartRequested = false; let findingWork = false; let availablePagination = { page: 1, total: 0, has_more: false }; let progress = null; let draft = null; let reviewFiles = []; let selectedReviewHead = ''; let activeInlineTarget = null; let bulkConfirmationPending = false; let bulkMarkPending = false; let reviewHandoffPending = false; let editingOutboxId = null; let confirmedOwnerLogin = ''; let planningOwnerLogin = ''; let activeFlushLogin = ''; let activeMyWork = []; let laterMyWork = []; let todayMyWork = []; let rolloverReviewPlan = null; const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const todayRollover = createTodayRollover(); const todayWork = createTodayWork({ storage: localStorage, getLogin: () => planningOwnerLogin, }); 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; todayWork.replacePlanning({ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, }); const reviewState = todayRollover.reviewState(plan); if (!['stale', 'legacy'].includes(reviewState)) { 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(() => openPlanToday(qs('#plan-today')), 0); } }, onStatus: (state, detail = {}) => { const status = qs('#today-sync-status'); status.textContent = state === 'saved' ? 'Today saved to account.' : (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 }); const laterWork = createLaterWork({ storage: localStorage, getLogin: () => planningOwnerLogin, onChange: (action, itemId, wakeAt) => { if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush(); }, onExpire: ids => { 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(); }, }); 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(); }); 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; } 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 = createMentionComposer({ textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'), status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository, loadCandidates:loadMentionCandidates, }); const pullMentions = createMentionComposer({ textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'), status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository, loadCandidates:loadMentionCandidates, }); const updateMentions = createMentionComposer({ textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'), status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository, loadCandidates:loadMentionCandidates, }); [issueMentions, pullMentions, updateMentions].forEach(controller => controller.start()); 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 }); const issueAttachmentController = issueAttachment.mount({ input: qs('#issue-attachment'), preview: qs('#issue-attachment-preview'), image: qs('#issue-attachment-image'), meta: qs('#issue-attachment-meta'), remove: qs('#remove-issue-attachment'), status: qs('#issue-comment-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: 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({ input: qs('#pull-attachment'), preview: qs('#pull-attachment-preview'), image: qs('#pull-attachment-image'), meta: qs('#pull-attachment-meta'), remove: qs('#remove-pull-attachment'), status: qs('#pull-comment-status'), 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({ input: 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'), status: qs('#update-reply-status'), readyMessage: 'Screenshot ready to send with this reply.', removedMessage: 'Screenshot removed. Your reply is unchanged.', 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 createIssueAttachmentController = issueAttachment.mount({ input: 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'), status: qs('#create-issue-attachment-status'), readyMessage: 'Screenshot ready to file with this issue.', removedMessage: 'Screenshot removed. Your issue draft is unchanged.', 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; const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null; const unfiledCaptures = createUnfiledCaptures({ storage: localStorage, attachmentStore: unfiledAttachmentStore, getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), getCurrentLogin: () => activeFlushLogin, }); const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)}); dFS.attach(qs, { captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController, getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; }, openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode, }); 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 authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, backgroundSync: backgroundIssueSync, getOwnerLogin: () => confirmedOwnerLogin, }); 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(() => { /* The foreground localStorage outboxes remain available. */ }); } const shareParams = new URLSearchParams(location.search); const sharedLaunch = { title: shareParams.get('title') || '', text: shareParams.get('text') || '', url: shareParams.get('url') || '', }; const commentActionFeatures = createFeatureLoader({ document, urls: { 'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '', }, }); await commentActionFeatures.run('comment-actions', { status: qs('#my-work-action-status'), retryLabel:'Reload to retry comment actions.', }, () => { commentActions = createCommentActions({ fetchJson: fetchReviewJson, getLogin: () => confirmedOwnerLogin, confirmDelete: message => window.confirm(message), }); }); 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; 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 }); } if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch); sharedLaunchHandled = true; } }); } if (Object.values(sharedLaunch).some(Boolean)) 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 }); 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; }); } if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); if (!wrapPreference) { wrapPreference = createReviewController.createWrapPreference({ storage: localStorage, mobile: window.matchMedia('(max-width: 600px)').matches, }); } }); } const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); 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); } 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; }, }); 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 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 response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/conversation?page=' + encodeURIComponent(page) + '&limit=20', { 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 notificationPager = createNotificationPager({ load: fetchNotificationPage, onNotifications: items => { lastNotifications = items; if (lastContextSnapshot) { lastContextSnapshot.notifications = lastNotifications; paintMyWork(lastContextSnapshot); } }, onPagination: pagination => { notificationPagination = pagination; 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') 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 notificationReader = createNotificationReader({ load: fetchNotificationDetail, loadConversation: fetchNotificationConversation, markRead: markNotificationRead, acknowledge: acknowledgeNotification, queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), onOpen: item => { if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) { updateReplyAttachmentController.clear(); } selectedUpdate = item; updateMentions.dismiss(); qs('#update-sheet').classList.add('open'); 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('#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('#update-ownership-action').hidden = true; qs('#update-ownership-start').hidden = true; qs('#retry-update-load').hidden = true; setOfflineUpdateControls(false); qs('#keep-update-unread').focus(); }, onDetail: async 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; 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, 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; qs('#retry-update-load').hidden = !message.startsWith('Could not load update.'); if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message; }, onClose: () => closeUpdateSheet(false), }); 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); } 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: item => { qs('#retry-work-route').hidden = true; qs('#my-work-action-status').textContent = ''; closeOpenWorkSheets(); if (item.kind === 'update') notificationReader.open(item, lastMyWork); else if (item.kind === 'review') openReviewSheet(item, reviewTrigger); else if (item.kind === 'issue') openIssueSheet(item, issueTrigger); else if (item.kind === 'pull') openPullSheet(item, pullTrigger); }, 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() { ['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-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 timer = createTodayTimer({ storage: localStorage, getLogin: () => confirmedOwnerLogin, }); 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 => { selectTodayWork(); workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity)); }, onComplete: identity => { const item = todayMyWork.find(entry => todayWork.identity(entry) === identity); return completeTodayItem(item); }, }); 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 todayRecapView = setupTodayRecap( timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions, () => planningOwnerLogin, identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null, actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes) ); 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(); qs('#start-work-session').hidden = active; qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable(); qs('#end-today-session').hidden = !active; updateDetailDeferLabels(active); mobileTaskDock.updateWork(mobileWorkEntry.mode()); mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention); } const workSession = createWorkSession({ getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork, getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter, getMilestone: () => selectedWorkMilestone, checkpoint: sessionCheckpoint, checkpointEnabled: () => 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(); }); }, onFinish: () => todayRecapView.finish(selectedWorkFilter), }); function selectTodayWork() { qs('[data-work-filter="today"]').click(); } 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; 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: message => { qs('#my-work-action-status').textContent = message; }, }); 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: message => { qs('#my-work-action-status').textContent = message; }, }); 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: message => { qs('#my-work-action-status').textContent = message; }, advance: () => runTodayTransition('complete'), }); 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; }, }); let planTodayTrigger = 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 || ''; 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-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) { if (navigate) { taskOverlayHistory.close(); return; } planToday.cancel(); qs('#plan-today-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); 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; } const planToday = createPlanToday({ identity: item => todayWork.identity(item), limit: todayWork.limit, save: saveTodayPlan, start: () => { qs('[data-work-filter="today"]').click(); startTodaySession(); }, }); 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(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 openPlanToday(trigger, navigate = true, actualMinutes = null) { if (!planningOwnerLogin) { qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; return; } if (trigger) planTodayTrigger = trigger; qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today'; if (actualMinutes) pendingPlanActualMinutes = actualMinutes; if (navigate) { taskOverlayHistory.open('plan-today'); return; } const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes; pendingPlanActualMinutes = null; planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations); 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(); qs('#cancel-plan-today').focus(); } 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: message => { qs('#my-work-action-status').textContent = message; }, formatTime: fmt, }); const laterPickerElement = qs('#later-picker'); const laterPickerInput = qs('#later-picker-time'); 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 === '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('#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(); } function listDrafts() { const unfiled = unfiledCaptures.list().map(item => ({ id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing', title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''), 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(unfiled).sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ); } function refreshMyWorkView({ reconcileSession = true } = {}) { lastDrafts = listDrafts(); const partitioned = laterWork.partition(lastMyWork, { pruneMissing: !Object.values(workPagination).some(page => page?.has_more), }); activeMyWork = partitioned.active; laterMyWork = partitioned.later; const authoritativeTodayReconciliation = liveMode && hasContextSnapshot && !lastContextSnapshot?.error && !Object.values(workPagination).some(page => page?.has_more); todayMyWork = todayWork.reconcile(lastMyWork, { pruneMissing: authoritativeTodayReconciliation, onPrune: retiredIds => { const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean); if (queued) todaySync.flush(); return queued; }, }); const counts = countMyWork(activeMyWork); counts.today = todayMyWork.length; counts.later = laterMyWork.length; counts.draft = lastDrafts.length; if (!launchFilterResolved) { selectedWorkFilter = mobileLaunch.chooseFilter({ saved: savedWorkFilter, today: counts.today, attention: counts.attention, }); 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; }); mobileTaskDock.updateQueues(counts); 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 (reconcileSession && workSession.active()) workSession.reconcile(); updateWorkSessionActions(); } function activeWorkStreams() { if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review']; if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review']; if (selectedWorkFilter === 'issue') return ['issue']; if (selectedWorkFilter === 'pull') return ['pull']; if (selectedWorkFilter === 'review') return ['review']; if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review']; return []; } function renderDrafts() { const list = qs('#my-work-list'); const deliveryCenter = draftInbox.partition(lastDrafts); 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 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' && closureOutbox ? '' + '' + '' : item.kind === 'authored-outbox' && !reviewOutbox ? '' + '' + '' : reviewOutbox ? '' + '' : '' + ''; const state = (isOutbox || isUnfiled) ? '' + (item.quarantined ? 'Identity protected' : (isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' : item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '' + (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 ? ' · ' + escapeHtml(item.repository) : '') + '' + '' + escapeHtml(item.title) + '' + '' + escapeHtml(item.preview || 'Unfinished draft') + '' + 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 + '
'; 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 safe 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. Items needing attention were skipped.'; }); list.querySelectorAll('.draft-resume').forEach(button => { button.addEventListener('click', async () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; if (!item) return; if (item.kind === 'unfiled-issue') { try { dFS.start(item.capture_id); await dFS.nextCapture(); } catch (error) { qs('#my-work-action-status').textContent = error.message; } } else if (item.kind === 'new-issue') openCreateIssueSheet(); 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.attachment) createIssueAttachmentController.restore(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-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.addEventListener('click', 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) 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.'; }); }); } function updateWorkPaginationControls() { const labels = { issue: 'issues', pull: 'pull requests', review: 'review 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; return; } const visible = selectedWorkFilter === 'today' ? filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ? filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); const selection = notificationSelection.snapshot(); const selectedIds = new Set(selection.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) + '' + '' + 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 selector = selectable ? '' : ''; const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') + (selectedIds.has(item.notification_id) ? ' 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('') : '
' + (incomplete ? 'More work is available. Load the next page.' : '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-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'); selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0; qs('#select-updates').hidden = selection.active; qs('#cancel-update-selection').hidden = !selection.active; qs('#update-selection-status').textContent = selection.active ? selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.'; bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active; qs('#load-more-notifications').hidden = selectedWorkFilter !== 'update' || !notificationPagination.has_more; bulkButton.disabled = bulkMarkPending || selection.count === 0; 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(); } function renderIssueComment(comment) { const actions = commentActions.actionHtml?.(comment) || ''; return '
' + escapeHtml(comment.author || 'Unknown author') + (comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') + '
' + actions + '
' + renderMarkdown(comment.body || 'No comment body provided.') + '
'; } function renderIssueConversation(state) { const comments = state?.comments || []; qs('#issue-comments').innerHTML = comments.length ? comments.map(renderIssueComment).join('') : '
No comments yet.
'; qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page); qs('#issue-conversation-status').textContent = comments.length ? comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; } function renderUpdateConversation(state) { const comments = state?.comments || []; qs('#update-comments').innerHTML = comments.length ? comments.map(renderIssueComment).join('') : '
No comments yet.
'; qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page); qs('#update-conversation-status').textContent = comments.length ? comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; } function renderPullConversation(state) { const comments = state?.comments || []; qs('#pull-comments').innerHTML = comments.length ? comments.map(comment => '
' + renderIssueComment(comment) + '
' ).join('') : '
No comments yet.
'; qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page); qs('#pull-conversation-status').textContent = comments.length ? comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; } function commentSurface(selector) { 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) { commentActions.wire({ root:qs(selector), getSurface:()=>commentSurface(selector), isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml, }); } wireCommentActions('#issue-comments'); wireCommentActions('#pull-comments'); wireCommentActions('#update-comments'); 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); } function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) { const select = qs('#issue-milestone'); const status = qs('#issue-milestone-status'); select.innerHTML = ''; select.innerHTML += milestones.map(milestone => '' ).join(''); const draft = issueController.loadMilestoneDraft(item); select.value = String(draft ?? confirmedMilestone?.id ?? ''); select.disabled = false; qs('#save-issue-milestone').disabled = false; status.textContent = confirmedMilestone ? 'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.'; } 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); renderIssueMilestoneEditor(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; } } async function openIssueSheet(item, trigger, offlineDetail = null) { if (!item) return; qs('#issue-planning').inert = false; qs('#issue-handoff').inert = false; selectedIssue = item; issueMentions.dismiss(); selectedIssueOffline = Boolean(offlineDetail); selectedIssueDetail = null; issueConversation = null; issueTrigger = trigger; qs('#issue-sheet').classList.add('open'); qs('#issue-sheet-key').textContent = item.key || ''; qs('#issue-sheet-title').textContent = item.title || 'Assigned issue'; qs('#issue-sheet-status').textContent = 'Loading issue…'; qs('#issue-sheet-body').textContent = ''; 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-status').textContent = ''; qs('#issue-handoff').open = false; qs('#issue-handoff-recipient').innerHTML = ''; qs('#issue-handoff-recipient').disabled = true; qs('#confirm-issue-handoff').disabled = true; qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'; qs('#load-issue-handoff').disabled = false; qs('#issue-handoff-status').textContent = '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').textContent = 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 detail = offlineDetail || await issueController.load(item); if (selectedIssue !== item) return; selectedIssueDetail = detail; renderPlanIssueDependencies(detail); issueConversation = issueController.conversation(item, detail.conversation); qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue'; qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); 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'; renderIssueConversation(issueConversation.snapshot()); qs('#open-issue-gitea').href = detail.url || item.url || '#'; qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open'); qs('#edit-issue-content').disabled = false; 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 ' + new Date(detail.due_date).toLocaleDateString() : '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 && createWorkRoute.parse(window.location.hash)) { workRoute.close(); return; } mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); issueAttachmentController.clear(); qs('#issue-sheet').classList.remove('open'); selectedIssue = null; selectedIssueOffline = false; selectedIssueDetail = null; issueConversation = null; if (issueTrigger?.isConnected) issueTrigger.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.mergeEligibility(detail, pullReviewState); 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 }; const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState); qs('#pull-merge-state').textContent = eligibility.reason; qs('#merge-pull').disabled = !eligibility.allowed; 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; if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear(); qs('#pull-review').inert = false; qs('#pull-ownership').inert = false; selectedPull = item; pullMentions.dismiss(); pullTrigger = trigger; selectedPullDetail = null; pullConversation = null; pullReviewState = null; qs('#pull-sheet').classList.add('open'); 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; 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')); pullAttachmentController.clear(); 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 renderAvailableIssues(items) { const list = qs('#find-work-list'); list.innerHTML = items.length ? items.map((item, index) => { const expanded = findWorkController.isPreviewed(item); const detailId = 'find-work-detail-' + index; const detail = '
' + renderMarkdown(item.body || 'No description provided.') + '
' + (item.url ? 'Open in Gitea' : '') + '
'; return '
' + 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-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'); 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(); } function saveIssueCaptureDraft() { if (!issueCapture) return; issueCapture.saveDraft({ repository: qs('#create-issue-repository').value, title: qs('#create-issue-title').value, body: qs('#create-issue-body').value, labelIds: selectedIssueLabelIds(), milestoneId: Number(qs('#create-issue-milestone').value) || null, dueDate: qs('#create-issue-due-date').value, }); } function currentIssueCaptureDraft() { return { repository: qs('#create-issue-repository').value, title: qs('#create-issue-title').value.trim(), body: qs('#create-issue-body').value.trim(), labelIds: selectedIssueLabelIds(), milestoneId: Number(qs('#create-issue-milestone').value) || null, dueDate: qs('#create-issue-due-date').value, }; } let issueCaptureRepositories = []; let nextIssueRepositoryPage = 2; let moreIssueRepositoriesAvailable = false; let issueRepositorySearchTimer = 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() { const hasRepository = Boolean(qs('#create-issue-repository').value); qs('#submit-new-issue').disabled = !hasRepository; qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available(); } 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 + '.'; loadIssueLabels(repository); loadIssueMilestones(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 loadIssueLabels(repository, selectedIds = []) { const list = qs('#create-issue-label-list'); const status = qs('#create-issue-label-status'); list.innerHTML = ''; if (!repository) { status.textContent = 'Choose a repository to load labels.'; return; } status.textContent = 'Loading labels…'; try { const labels = await issueCapture.loadLabels(repository); const selected = new Set(selectedIds.map(Number)); list.innerHTML = labels.map(label => '' ).join(''); status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.'; } catch (error) { status.textContent = 'Labels could not be loaded. You can still create the issue without labels.'; } } async function loadIssueMilestones(repository, selectedId = null) { const select = qs('#create-issue-milestone'); const status = qs('#create-issue-milestone-status'); select.innerHTML = ''; if (!repository) { status.textContent = 'Choose a repository to load milestones.'; return; } status.textContent = 'Loading milestones…'; try { const milestones = await issueCapture.loadMilestones(repository); if (qs('#create-issue-repository').value !== repository) return; select.innerHTML += milestones.map(milestone => '' ).join(''); if (selectedId) select.value = String(selectedId); status.textContent = milestones.length ? 'Choose the release lane for this issue.' : 'This repository has no open milestones.'; } catch (_error) { if (qs('#create-issue-repository').value !== repository) return; status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.'; } } 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; } async function openCreateIssueSheet(navigate = true) { if (!issueCapture && !await ensureIssueCapture()) return; if (navigate) { taskOverlayHistory.open('new'); return; } const captureDraft = issueCapture.loadDraft(); 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 || ''; loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); scheduleIssueDuplicateCheck(); qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.'; updateIssueCreateActions(); qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; qs('#create-issue-title').focus(); } function setIssueFilingMode(enabled) { qs('#create-issue-filing').hidden = !enabled; qs('.create-issue-capture-actions').hidden = enabled; qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work'; } let suppressCreateDraftOnHistoryClose = false; function closeCreateIssueSheet(navigate = true, preserveDraft = true) { if (navigate && taskOverlayHistory.current() === 'new') { suppressCreateDraftOnHistoryClose = !preserveDraft; taskOverlayHistory.close(); return; } qs('#create-issue-sheet').classList.remove('open'); mobileComposerViewport.close(qs('.create-issue-panel')); clearTimeout(duplicateCheckTimer); qs('#create-issue-duplicates').hidden = true; creatingIssue = false; qs('#new-issue').focus(); } function applyOutboxResult(result, openCreated = false, startCreated = false) { if (result.lease_skipped) { refreshMyWorkView(); return; } (result.confirmed || []).forEach(confirmed => { 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); 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]; 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 (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() { 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; 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 (navigate && createWorkRoute.parse(window.location.hash)) { workRoute.close(); return; } 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')); updateReplyAttachmentController.clear(); qs('#update-sheet').classList.remove('open'); selectedUpdate = 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 && workChanged) { setOfflineWorkMode(false); const retainedPlanningLogin = !snapshot.context.error ? String(snapshot.context.user?.login || '').trim() : ''; planningOwnerLogin = retainedPlanningLogin; updatePlanningAvailability(); if (planningOwnerLogin) { todaySync.migrate(todayWork.read()); todaySync.flush(); laterSync.migrate(laterWork.read()); laterSync.flush(); } const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && !contextFreshness?.degraded && !contextFreshness?.revalidating; activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; if (activeFlushLogin) { confirmedOwnerLogin = activeFlushLogin; updateDeliveryReceiptControls(); interruptionPrompt.restore(); } 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'); } // Compatibility fallback for snapshots produced before section metadata. 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'); } } 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'); } /* Creative ambient background */ (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(); }); } /* Markdown */ qs('#md-input').addEventListener('input', renderMD); function renderMD() { const raw = qs('#md-input').value || ''; qs('#md-preview').innerHTML = '
' + escapeHtml(raw) + '
' + renderMarkdown(raw) + '
'; } /* Commands */ const commands = [ { name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } }, { name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } }, { name: 'Refresh now', run: load }, { name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) }, ]; let commandSearchState = { status:'idle', query:'', items:[] }; let commandItems = []; let commandSelection = -1; async function searchGlobalWork(query, signal) { const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', { headers: { Accept:'application/json' }, signal, }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.'); return { items: Array.isArray(payload.items) ? payload.items : [], partial: payload.partial === true, }; } const commandSearch = filterCommands.createGlobalSearchController({ search: searchGlobalWork, onState: state => { commandSearchState = state; renderCommands(state.query); }, }); function safeSearchUrl(value) { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; } catch (_) { return ''; } } let searchPreviewDetail = null; function searchPreviewPath(item) { const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/preview?kind=' + encodeURIComponent(item.kind); } function renderSearchPreview(state) { const sheet = qs('#search-preview'); const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); const startButton = qs('#start-search-result'); qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' ? 'Back to blockers' : 'Back to search'; if (state.status === 'closed') { sheet.classList.remove('open'); return; } sheet.classList.add('open'); claimButton.hidden = true; claimButton.disabled = false; startButton.hidden = true; startButton.disabled = false; if (state.status === 'loading') { 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 = ''; 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; 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.'); qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#'; claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')); claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.disabled = state.status === 'claiming'; startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))); startButton.textContent = detail.reopenable ? 'Reopen & resume' : (detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; status.textContent = state.status === 'reopening' ? 'Reopening…' : (state.status === 'claiming' ? 'Assigning this issue to you…' : (state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' : (detail.claimable ? 'This issue is open and unassigned.' : (detail.assigned_to_me ? 'This issue is already in My Work.' : (detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.'))))); } const searchPreview = createSearchPreview({ fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), mutate: (detail, action) => fetchReviewJson( 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(detail.number) + '/' + action, { method:'PATCH', headers:{ Accept:'application/json' } } ), onState: renderSearchPreview, }); function createSearchStart(claim) { return createAssignAndStart({ available: createAndStart.available, claim, start: confirmed => { const item = acceptClaimedIssue(confirmed); taskOverlayHistory.leave(); refreshMyWorkView(); return createAndStart.complete(item); }, recover: confirmed => { const item = acceptClaimedIssue(confirmed); taskOverlayHistory.leave(); refreshMyWorkView(); openRoutedWork(item, qs('#open-palette')); }, announce: message => { qs('#search-preview-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 mobileSearchViewport = createMobileSearchViewport({ palette: qs('#cmd-palette'), results: qs('#cmd-results'), viewport: window.visualViewport, mediaQuery: window.matchMedia('(max-width: 600px)'), schedule: callback => requestAnimationFrame(callback), }); function closeSearchPreview(navigate = true) { 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); qs('#cmd-input').focus(); } async function openPreviewIssueInMyWork(detail) { await load(); const item = lastMyWork.find(candidate => candidate.kind === 'issue' && 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(() => {}); taskOverlayHistory.open('search-preview'); } qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').setAttribute('aria-expanded', 'false'); } function renderCommands(filter) { const el = qs('#cmd-results'); const local = 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; 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; el.querySelectorAll('.cmd-item').forEach((item) => { item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)])); }); } function openCommandPalette(navigate = true) { if (navigate) { taskOverlayHistory.open('search'); return; } qs('#cmd-palette').classList.add('open'); mobileSearchViewport.open(); mobileSearchViewport.restoreScroll(); qs('#cmd-input').setAttribute('aria-expanded', 'true'); qs('#cmd-input').focus(); commandSelection = -1; renderCommands(qs('#cmd-input').value); } const taskOverlayHistory = createTaskOverlayHistory({ history: window.history, eventTarget: window, onChange(kind, previous) { 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(); } 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(); qs('#open-palette').focus(); } 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') openCommandPalette(false); 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(); qs('#open-palette').addEventListener('click', openCommandPalette); qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#cmd-input').addEventListener('input', (e) => { commandSelection = -1; 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(); 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.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 openPreviewIssueInMyWork(claimed); if (!opened) { qs('#search-preview-status').textContent = 'Assignment confirmed. Refresh My Work to open the issue.'; } } catch (error) { qs('#search-preview-status').textContent = error.message + ' Retry assignment.'; } }); qs('#start-search-result').addEventListener('click', async () => { const detail = searchPreviewDetail; 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('#close-find-work').addEventListener('click', closeFindWorkSheet); 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('#new-issue').addEventListener('click', openCreateIssueSheet); 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 captureDraft = { title: qs('#create-issue-title').value.trim(), body: qs('#create-issue-body').value.trim(), attachment: await createIssueAttachmentController.serialize(), }; if (showDraftCapacityDialog(unfiledCaptures)) return; const savedCapture = await unfiledCaptures.save(captureDraft); if (rUC) { await unfiledCaptures.completeResume(rUC); rUC = ''; } issueCapture.clearDraft(); qs('#create-issue-title').value = ''; qs('#create-issue-body').value = ''; createIssueAttachmentController.clear(); closeCreateIssueSheet(true, false); qs('[data-work-filter="draft"]').click(); mobileTaskDock.select('queues'); refreshMyWorkView(); const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]'); requestAnimationFrame(() => { savedCard?.scrollIntoView({block:'nearest'}); savedCard?.focus({preventScroll:true}); }); qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.'; } catch (error) { qs('#create-issue-capture-status').textContent = error.message; qs('#create-issue-title').focus(); } }); bindDraftCapacityDialog(); qs('#use-shared-content').addEventListener('click', () => { 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(); 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', () => { 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(); }) ); qs('#create-issue-repository').addEventListener('change', event => { loadIssueLabels(event.target.value); loadIssueMilestones(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('#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(); }); qs('#create-issue-form').addEventListener('submit', async event => { event.preventDefault(); if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue'; 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; } const button = qs('#submit-new-issue'); const startButton = qs('#create-and-start-issue'); button.disabled = true; startButton.disabled = true; qs('#create-issue-status').textContent = createAndStartRequested ? 'Creating issue and adding it to Today…' : 'Saving for background delivery…'; try { const durableDraft = { ...captureDraft, attachment: await createIssueAttachmentController.serialize(), ...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}), }; const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) : await issueOutbox.enqueueDurably(durableDraft); const queued = admission.item; const fS = dFS.current(); if (rUC && (!durableDraft.attachment || 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.'; button.disabled = false; startButton.disabled = !createAndStart.available(); return; } editingOutboxId = null; 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.'; button.disabled = false; startButton.disabled = !createAndStart.available(); qs('#create-issue-title').focus(); } }); qs('#close-issue-sheet').addEventListener('click', closeIssueSheet); qs('#retry-issue-load').addEventListener('click', () => { if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger); }); 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; 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', () => { 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); lastContextSnapshot = buildMyWork.replaceIssueContent( lastContextSnapshot, editing.repository, editing.number, confirmed ); selectedIssue = { ...editing, ...confirmed, key: editing.key }; selectedIssueDetail = { ...selectedIssueDetail, ...confirmed }; qs('#issue-sheet-title').textContent = confirmed.title; qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.'); paintMyWork(lastContextSnapshot); 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 { 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; attachmentController.clear(); 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(); issueAttachmentController.clear(); 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(); issueAttachmentController.clear(); 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) renderIssueConversation(issueConversation.append(comment)); qs('#issue-comment').value = ''; issueAttachmentController.clear(); 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(); if (admission.background) { qs('#issue-comment').value = ''; issueAttachmentController.clear(); 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); select.textContent = ''; const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates'; select.appendChild(placeholder); candidates.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 = !candidates.length; qs('#confirm-issue-handoff').disabled = true; qs('#issue-handoff-status').textContent = candidates.length ? 'Choose who should own this issue next.' : 'No other eligible assignees were found.'; if (candidates.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 || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) 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 { 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 || !window.confirm('Close ' + selectedIssue.key + '?')) 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 closure queued, but Today still needs completion.'; } else if (outcome.admission.background) { qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.'; } else { qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. 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 { await issueController.close(selectedIssue); closeIssueSheet(); 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 + ' 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 = ''; pullAttachmentController.clear(); 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 = ''; pullAttachmentController.clear(); 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 { await pullController.merge(selectedPull, selectedPullDetail.head_sha); 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(); } }); qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); 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('#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 = ''; updateReplyAttachmentController.clear(); refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Reply queued for sync.'; } else if (result) { notificationReader.appendReply(result); qs('#update-reply').value = ''; updateReplyAttachmentController.clear(); 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) updateReplyAttachmentController.clear(); 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.'; } 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', async () => { qs('#mark-update-read-next').disabled = true; try { await notificationReader.markReadAndNext(lastMyWork); } finally { qs('#mark-update-read-next').disabled = false; } }); 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) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.'; } finally { button.disabled = offlineWorkMode; } }); 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 (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 (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, }); 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 }); } 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(); planningOwnerLogin = confirmedOwnerLogin; interruptionPrompt.restore(); updatePlanningAvailability(); 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(); } function reconnectLiveData() { offlineStatus.hidden = true; setOfflineWorkMode(false); setStatus('Reconnecting…'); contextPoller.refresh({ force: true }).then(() => { if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); }); } 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); window.addEventListener('online', reconnectLiveData); qs('#refresh').addEventListener('click', load); qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget)); qs('#cancel-plan-today').addEventListener('click', closePlanToday); 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(); }); function commitPlanToday(start, button) { const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' }); if (result === 'saved') { button.dataset.confirmOverCapacity = ''; 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 = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter); if (!sessionItems.length) { qs('#my-work-action-status').textContent = 'No visible work to start.'; return; } if (selectedWorkFilter === 'today') runTodayTransition('start'); 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 () => { 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' ? (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-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(); }); 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', () => { const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update'; selectedWorkFilter = button.dataset.workFilter; if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel(); savedWorkFilter = selectedWorkFilter; launchFilterResolved = true; try { sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter); } catch (e) { console.warn('Could not persist My Work filter', 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(); updateWorkPaginationControls(); }); }); function openDeliveryReceiptRoute() { if (window.location.hash !== '#/my-work/drafts') return; qs('[data-work-filter="draft"]').click(); qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } window.addEventListener('hashchange', openDeliveryReceiptRoute); openDeliveryReceiptRoute(); qs('#work-milestone-filter').addEventListener('change', event => { selectedWorkMilestone = event.target.value; try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); } catch (e) { console.warn('Could not persist My Work milestone lane', e); } renderMyWork(); if (workSession.active()) workSession.reconcile(); }); 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'), notification:window.Notification, serviceWorker:navigator.serviceWorker, fetchJson:fetchReviewJson, }); await controller.init(); return controller; }).catch(error => { qs('#push-updates').disabled = true; qs('#push-update-status').textContent = 'Update notification settings unavailable.'; console.warn('Push notifications unavailable', error); return null; }); } else { qs('#push-updates').disabled = true; qs('#push-update-status').textContent = 'This browser does not support update notifications.'; } 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(); deviceSetup = createMobileDeviceSetup.mount({ document, installApp, promptStorage:localStorage, offlineAvailable:() => offlineStorageReady, offlineEnabled:() => offlineWorkStore.enabled(), enableOffline:() => setOfflineWorkEnabled(true), enablePush:async () => { const controller = await pushControllerReady; if (!controller) return; qs('#push-updates').checked = true; await controller.change(); }, }); 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); contextPoller.start(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); if (!document.hidden) deviceSetup?.render(); }); /* Widgets */ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); })();