(function(){ const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); 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') }, ], }); 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')); function openMobileWork() { const counts = countMyWork(activeMyWork); const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all'); qs('[data-work-filter="' + filter + '"]').click(); qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } const mobileTaskDock = createMobileTaskDock({ nav: qs('#mobile-task-dock'), buttons: mobileTaskButtons, attentionBadge: qs('#mobile-attention-count'), overlays: mobileTaskOverlays, actions: { work: openMobileWork, find: () => qs('#find-work').click(), new: () => qs('#new-issue').click(), search: () => qs('#open-palette').click(), drafts: () => qs('[data-work-filter="draft"]').click(), }, observe(callback, overlays) { const observer = new MutationObserver(callback); overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']})); return observer; }, }); mobileTaskDock.start(); const sourceDraftCount = qs('[data-work-count="draft"]'); const draftCount = qs('#mobile-draft-count'); function syncMobileDraftCount() { draftCount.textContent = sourceDraftCount.textContent; draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts'); } new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true}); syncMobileDraftCount(); 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 selectedIssueDetail = 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 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 = []; const todayWork = createTodayWork({ storage: localStorage, getLogin: () => planningOwnerLogin, }); const todaySync = createTodaySync({ storage: localStorage, getLogin: () => planningOwnerLogin, fetchJson: fetchReviewJson, onRemoteIds: ids => { if (!planningOwnerLogin || !todayWork.replace(ids)) return; refreshMyWorkView(); warmTodayOffline(); }, 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.' : '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, onRemoteRecords: records => { if (!planningOwnerLogin || !laterWork.adopt(records)) return; refreshMyWorkView(); }, onStatus: (state, detail = {}) => { qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' : (state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` : (state === 'pending' ? 'Later saved on this device · sync pending.' : 'Later sync unavailable · changes stay on this device.')); }, }); laterSync.startLifecycle({ window, document }); document.addEventListener('visibilitychange', () => { 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; } 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; } const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const planningLoader = createIssueSheet.createPlanningLoader({ loadLabels: item => issueController.loadLabels(item), loadMilestones: item => issueController.loadMilestones(item), }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const unfiledCaptures = createUnfiledCaptures({ storage: localStorage, getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), getCurrentLogin: () => activeFlushLogin, }); 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 outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); 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 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') || '', }; let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ? issueCapture.stageSharedContent(sharedLaunch) : null; const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); outboxCoordinator.subscribe(() => refreshMyWorkView()); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); 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: item => item.is_review ? reviewController.load(item) : item.kind === 'pull' ? pullController.load(item) : 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 params = new URLSearchParams(); Object.entries(revisions).forEach(([section, revision]) => { if (Number.isInteger(revision) && revision >= 0) params.set(section + '_revision', revision); }); const query = params.toString(); const res = await fetch('api/v1/live' + (query ? '?' + query : ''), { headers: { Accept: 'application/json' }, signal, }); if (!res.ok) throw new Error('HTTP ' + res.status); 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 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 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({ 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; }, 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; }, }); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, loadConversation: fetchNotificationConversation, markRead: markNotificationRead, queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), onOpen: item => { selectedUpdate = item; 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('#update-ownership-action').hidden = true; qs('#retry-update-load').hidden = true; setOfflineUpdateControls(false); qs('#keep-update-unread').focus(); }, onDetail: 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 || '#'; if (offlineWorkMode) { setOfflineUpdateControls(true); } else { updateOwnership.open(detail, selectedUpdate); if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) { 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) { openRoutedWork(item, null, { replace:true }); } const workSession = createWorkSession({ getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork, getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter, getMilestone: () => selectedWorkMilestone, onOpen: openWorkSessionItem, onProgress: state => { document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; }); document.querySelectorAll('[data-work-session-progress]').forEach(element => { element.textContent = 'Item ' + state.index + ' of ' + state.total; }); 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'; }); }, onFinish: () => { closeOpenWorkSheets(); document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; }); qs('#my-work-action-status').textContent = 'Work session complete.'; qs('#start-work-session').focus(); }, }); const detailDefer = createDetailDefer({ laterWork, session: workSession, 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', '#close-issue', '#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', ] : [ '#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; } } function setOfflineUpdateControls(offline) { qs('#mark-update-read-next').disabled = false; qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next'; qs('#update-ownership-action').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(); } function handleContextError(e) { console.error('context failed', e); liveMode = false; activeFlushLogin = ''; if (!hasContextSnapshot && 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, 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() { 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.updateAttention(counts.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 (workSession.active()) workSession.reconcile(); } 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'); list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const isUnfiled = item.kind === 'unfiled-issue'; const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now'; const outboxActions = item.quarantined ? '' + '' : item.kind === 'issue-outbox' ? '' + '' + '' : item.kind === 'authored-outbox' ? '' + '' + '' : '' + ''; const state = (isOutbox || isUnfiled) ? '' + (item.quarantined ? 'Identity protected' : (isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; return '
' + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + '' + escapeHtml(item.title) + '' + '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + '
' + outboxActions + '
'; }).join('') : '
No unfinished drafts.
'; list.querySelectorAll('.draft-resume').forEach(button => { button.addEventListener('click', () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; if (!item) return; if (item.kind === 'unfiled-issue') { try { const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin); issueCapture.saveDraft(resumed); refreshMyWorkView(); openCreateIssueSheet(); qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.'; } 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-edit').forEach(button => { button.addEventListener('click', () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id); if (!queued) return; editingOutboxId = queued.id; issueCapture.saveDraft(queued); openCreateIssueSheet(); qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; }); }); 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-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', () => { if (!window.confirm('Discard this unfinished draft?')) return; const item = lastDrafts[Number(button.dataset.draftIndex)]; if (item?.kind === 'unfiled-issue') 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); 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 markRead = item.has_update && Number.isInteger(item.notification_id) ? '' : ''; 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 = todayActions + laterActions; if (item.is_review) { return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; } if (item.kind === 'issue') { return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; } if (item.kind === 'pull') { return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; } return '
' + 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'))))) + '.') + '
'; 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-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 allIds = notificationIds(visible); const ids = allIds.slice(0, Math.min(lastNotifications.length, 50)); const bulkBar = qs('#bulk-mark-read-bar'); const bulkButton = qs('#bulk-mark-read'); bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0; qs('#load-more-notifications').hidden = selectedWorkFilter !== 'update' || !notificationPagination.has_more; bulkButton.disabled = bulkMarkPending; const bulkLabel = allIds.length > ids.length ? 'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' : 'all ' + ids.length + ' updates'; bulkButton.textContent = bulkConfirmationPending ? 'Confirm marking ' + bulkLabel + ' read' : 'Mark ' + bulkLabel + ' read'; workRoute.setItems(lastMyWork); } function reviewFileElement(filename) { return Array.from(document.querySelectorAll('.review-file')).find( element => element.dataset.reviewFilename === filename ); } 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) { return '
' + escapeHtml(comment.author || 'Unknown author') + (comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') + '
' + 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 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; 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('#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; 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-sheet').focus(); try { const detail = offlineDetail || await issueController.load(item); if (selectedIssue !== item) return; selectedIssueDetail = 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)) { 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')); qs('#issue-sheet').classList.remove('open'); selectedIssue = null; selectedIssueDetail = null; issueConversation = null; if (issueTrigger?.isConnected) issueTrigger.focus(); } function renderPullReview(detail, focusFilename = null) { 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 focusNextUnreviewedPullFile() { if (!selectedPullDetail || !pullReviewState) return; const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState); const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file')) .find(file => file.dataset.pullFilename === filename); const toggle = article?.querySelector('.pull-file-toggle'); const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls')); if (toggle && panel) { toggle.setAttribute('aria-expanded', 'true'); panel.hidden = false; toggle.scrollIntoView({ block: 'center', behavior: 'smooth' }); toggle.focus(); } } async function loadPullReview() { 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); 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; qs('#pull-review').inert = false; selectedPull = item; 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-merge-state').textContent = 'Review data not loaded'; qs('#merge-pull').disabled = true; qs('#retry-pull-load').hidden = true; qs('#open-pull-gitea').href = item.url || '#'; 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)) { 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')); 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); lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] }; lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); lastMyWork = buildMyWork(lastContextSnapshot); const claimed = lastMyWork.find(work => work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number ); 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(); } }); }); } 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() { 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 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; } function openCreateIssueSheet(navigate = true) { if (navigate) { taskOverlayHistory.open('new'); return; } const captureDraft = issueCapture.loadDraft(); const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); if (captureDraft.repository && !repositories.includes(captureDraft.repository)) { repositories.unshift(captureDraft.repository); } qs('#create-issue-repository').innerHTML = repositories.map(repository => '' ).join(''); if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository; qs('#create-issue-title').value = captureDraft.title; qs('#create-issue-body').value = captureDraft.body; 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 = repositories.length ? '' : 'No accessible repositories are available.'; qs('#submit-new-issue').disabled = !repositories.length; qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; qs('#create-issue-title').focus(); } 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'); clearTimeout(duplicateCheckTimer); qs('#create-issue-duplicates').hidden = true; creatingIssue = false; qs('#new-issue').focus(); } function applyOutboxResult(result, openCreated = 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(); 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 ); if (openCreated && created) 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) { 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 = ''; 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('#submit-review').disabled = true; qs('#submit-review').textContent = offlineReview ? 'Reconnect to validate & submit' : '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'); 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 = offlineReview; } 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')); 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; } 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(); } snapshot.context.notifications = lastNotifications; renderContextSnapshot(snapshot.context); if (contextFreshness?.stale) markMyWorkStale(); else if (!notificationsFresh) markNotificationsStale(); if (!contextFreshness?.stale && notificationsFresh) { offlineWorkStore.save({ ...snapshot.context, notifications: snapshot.notifications, notification_pagination: snapshot.notification_pagination, }); updateOfflineWorkControls(); 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'); if (state.status === 'closed') { sheet.classList.remove('open'); return; } sheet.classList.add('open'); claimButton.hidden = true; claimButton.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'; status.textContent = 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.' : 'Read-only preview.'))); } const searchPreview = createSearchPreview({ fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), claim: detail => fetchReviewJson( 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(detail.number) + '/claim', { method:'PATCH', headers:{ Accept:'application/json' } } ), onState: renderSearchPreview, }); 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(); 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(); } } 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 (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); if (kind === 'find' && previous !== 'find') openFindWorkSheet(false); if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false); }, }); 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('#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); qs('#save-unfiled-issue').addEventListener('click', () => { const captureDraft = { title: qs('#create-issue-title').value.trim(), body: qs('#create-issue-body').value.trim(), }; try { unfiledCaptures.save(captureDraft); issueCapture.clearDraft(); qs('#create-issue-title').value = ''; qs('#create-issue-body').value = ''; closeCreateIssueSheet(true, false); refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.'; } catch (error) { qs('#create-issue-status').textContent = error.message; qs('#create-issue-title').focus(); } }); 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); saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); }); 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(); 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; } const button = qs('#submit-new-issue'); button.disabled = true; qs('#create-issue-status').textContent = 'Saving for background delivery…'; try { const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) : await issueOutbox.enqueueDurably(captureDraft); const queued = admission.item; if (!admission.background) { editingOutboxId = queued.id; refreshMyWorkView(); qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.'; button.disabled = false; return; } editingOutboxId = null; issueCapture.clearDraft(); 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); } catch (error) { qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; button.disabled = false; 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; } }); qs('#send-issue-comment').addEventListener('click', async () => { if (!selectedIssue) return; const body = qs('#issue-comment').value.trim(); if (!body) { 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 = 'Posting comment…'; try { const comment = await issueController.comment(selectedIssue, body); if (issueConversation) renderIssueConversation(issueConversation.append(comment)); qs('#issue-comment').value = ''; 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, operationId }); refreshMyWorkView(); if (admission.background) { qs('#issue-comment').value = ''; qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; } else { qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; qs('#issue-comment').focus(); } } else { qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.'; qs('#issue-comment').focus(); } } finally { button.disabled = false; } }); qs('#release-issue').addEventListener('click', async () => { if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return; const releasing = selectedIssue; const 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(); 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 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(); 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; 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(); 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('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile); 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 body = qs('#pull-comment').value.trim(); if (!body) { qs('#pull-comment-status').textContent = 'Write a comment before posting.'; qs('#pull-comment').focus(); return; } const button = qs('#send-pull-comment'); button.disabled = true; qs('#pull-comment-status').textContent = 'Posting comment…'; try { const comment = await pullController.comment(selectedPull, body); if (pullConversation) renderPullConversation(pullConversation.append(comment)); qs('#pull-comment').value = ''; qs('#pull-comment-status').textContent = 'Comment posted.'; } catch (error) { if (canQueueMessage(error)) { const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation'); qs('#pull-comment-status').textContent = 'Saving for background delivery…'; const admission = await authoredOutbox.enqueueDurably({ kind:'pull-comment', repository:selectedPull.repository, number:selectedPull.number, body, operationId }); refreshMyWorkView(); if (admission.background) { qs('#pull-comment').value = ''; qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.'; } else { qs('#pull-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; qs('#pull-comment').focus(); } } else { qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.'; qs('#pull-comment').focus(); } } finally { 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(); 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('#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(); if (!body) { qs('#update-reply-status').textContent = 'Write a reply before sending.'; qs('#update-reply').focus(); return; } qs('#send-update-reply').disabled = true; const result = await notificationReplier.submit(selectedUpdate, body); qs('#send-update-reply').disabled = false; if (result?.queued) { qs('#update-reply').value = ''; refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Reply queued for sync.'; } else if (result) { notificationReader.appendReply(result); qs('#update-reply').value = ''; qs('#mark-update-read-next').focus(); } else { qs('#update-reply').focus(); } }); 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('#close-review-sheet').addEventListener('click', closeReviewSheet); qs('#retry-review-load').addEventListener('click', () => { if (selectedReview) openReviewSheet(selectedReview, reviewTrigger); }); qs('#next-unreviewed-review').addEventListener('click', () => { if (progress) openNextUnreviewed(progress.snapshot()); }); 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; 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()) workSession.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: error => { 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 deliveryReceipts = qs('#delivery-receipts'); const deliveryReceiptStatus = qs('#delivery-receipt-status'); function updateOfflineWorkControls(message) { keepWorkOffline.checked = offlineWorkStore.enabled(); const saved = offlineWorkStore.load(); offlineWorkStatus.textContent = message || (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; }); } } function hydrateOfflineWork(mode = 'offline') { const saved = offlineWorkStore.load(); if (!saved) return false; const outage = mode === 'outage'; confirmedOwnerLogin = String(saved.user?.login || '').trim(); planningOwnerLogin = confirmedOwnerLogin; 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; } function showOfflineStatus() { activeFlushLogin = ''; offlineStatus.hidden = false; setStatus('Offline'); if (!hasContextSnapshot) hydrateOfflineWork(); } function reconnectLiveData() { offlineStatus.hidden = true; setOfflineWorkMode(false); setStatus('Reconnecting…'); contextPoller.refresh({ force: true }).then(() => { if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); }); } keepWorkOffline.addEventListener('change', () => { offlineWorkStore.setEnabled(keepWorkOffline.checked); if (keepWorkOffline.checked && liveMode && lastContextSnapshot) { offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, notification_pagination:notificationPagination }); } updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); if (keepWorkOffline.checked) warmTodayOffline(); else offlineToday.cancel(); }); 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', () => { offlineWorkStore.clear(); offlineToday.cancel(); renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 }); updateOfflineWorkControls('Offline work data cleared.'); }); qs('#retry-offline-today').addEventListener('click', () => offlineToday.retry(confirmedOwnerLogin, todayMyWork) ); updateOfflineWorkControls(); updateDeliveryReceiptControls(); if (!navigator.onLine) showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); window.addEventListener('online', reconnectLiveData); qs('#refresh').addEventListener('click', load); 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; } workSession.start(); }); 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.next()) ); 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('#bulk-mark-read').addEventListener('click', async () => { const allIds = notificationIds(filterMyWork(lastMyWork, 'update')); const ids = allIds.slice(0, 50); if (!ids.length || bulkMarkPending) return; if (!bulkConfirmationPending) { bulkConfirmationPending = true; qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.'; renderMyWork(); return; } bulkConfirmationPending = false; bulkMarkPending = true; renderMyWork(); const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids); if (result) { const marked = new Set(result.marked); lastNotifications = lastNotifications.filter(item => !marked.has(item.id)); } bulkMarkPending = false; renderMyWork(); (document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus(); }); document.querySelectorAll('[data-work-filter]').forEach(button => { button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter)); button.addEventListener('click', () => { selectedWorkFilter = button.dataset.workFilter; 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(); }); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('service-worker.js').catch(error => console.warn('Stackchain install support unavailable', error) ); } const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent); createInstallApp({ window, 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: () => isIosSafari, }).start(); contextPoller.start(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); }); /* Widgets */ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); })();