stackchain-dashboard/frontend/dashboard.js
timmy 5e6328a8d0
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: send mobile deadline reminders (Closes #709)
2026-08-13 05:00:32 +00:00

6114 lines
290 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(async function(){
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const cardPlanning = createCardPlanning(document);
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
entries: [
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
],
});
mobileComposerViewport.start();
[
[qs('.app-menu'), qs('#app-menu-toggle')],
[qs('.work-settings'), qs('#work-settings-toggle')],
].forEach(([disclosure, launcher]) => {
mobileLaunch.createDisclosure({disclosure, launcher}).start();
});
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
let savedState = {};
try {
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
} catch (e) {
savedState = {};
}
panels.forEach((panel) => {
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
panel.open = savedState[panel.dataset.panelKey];
}
panel.addEventListener('toggle', () => {
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
try {
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
} catch (_) {}
});
});
const mobileTaskButtons = Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
);
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
const attentionInterruption = qs('#attention-interruption');
function renderAttentionInterruption() {
const pending = timer.attentionInterruption();
attentionInterruption.hidden = !pending;
return pending;
}
function selectMobileQueue(name) {
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
renderAttentionInterruption();
timerView.render();
qs('[data-work-filter="' + name + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
getEligibleCount: () => activeMyWork.length,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
findWork: () => qs('#find-work').click(),
});
let mobileQueueCounts = {};
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: message => { qs('#my-work-action-status').textContent = message; },
getCounts: () => mobileQueueCounts,
openFindWork: () => qs('#find-work').click(),
});
function showMobileQueueCompletion(completedName) {
const next = mobileQueueLauncher.recommend();
qs('#mobile-queue-heading').textContent = completedName + ' cleared';
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
const row = qs('[data-mobile-queue="' + next.name + '"]');
row.setAttribute('data-recommended', 'true');
qs('#mobile-queue-sheet').showModal();
row.focus();
}
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
queueSheet: qs('#mobile-queue-sheet'),
queueClose: qs('#close-mobile-queues'),
queueRows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
queueCounts: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
),
queueBadge: qs('#mobile-queue-count'),
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => {},
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']}));
return observer;
},
});
mobileTaskDock.start();
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all';
let selectedWorkMilestone = 'all';
let queueFindQuery = '';
let savedWorkFilter = null;
let launchFilterResolved = false;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'today', 'agenda', '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('Filter restore failed', e);
}
let lastMyWork = [];
let lastDrafts = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let workPagination = {};
let agendaChecking = false;
let agendaReplan = null;
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let offlineReview = false;
let selectedUpdate = null;
let selectedUpdateDetail = null;
let followUpSourceUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null;
let issueConversation = null;
let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let activeInlineTarget = null;
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let activeFlushLogin = '';
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
let rolloverReviewPlan = null;
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayRollover = createTodayRollover();
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView();
warmTodayOffline();
},
onRemotePlan: plan => {
if (!planningOwnerLogin) return;
todayWork.replacePlanning({
capacity_minutes: plan.capacity_minutes ?? null,
estimates: plan.estimates || {},
});
const reviewState = todayRollover.reviewState(plan);
if (!['stale', 'legacy'].includes(reviewState)) {
rolloverReviewPlan = null;
qs('#plan-today').textContent = 'Plan Today';
return;
}
rolloverReviewPlan = plan;
qs('#plan-today').textContent = 'Review new day';
qs('#my-work-action-status').textContent = 'Review yesterdays unfinished work before starting today.';
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if (!localStorage.getItem(marker)) {
localStorage.setItem(marker, '1');
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
}
},
onStatus: (state, detail = {}) => {
const status = qs('#today-sync-status');
status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Today sync unavailable · changes stay on this device.'))));
},
});
todaySync.startLifecycle({ window, document });
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt) => {
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
},
onExpire: ids => {
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
},
});
const laterSync = createLaterSync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteRecords: records => {
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
refreshMyWorkView();
},
onStatus: (state, detail = {}) => {
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
(state === 'expired' ? `Later edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Later sync unavailable · changes stay on this device.'))));
},
});
laterSync.startLifecycle({ window, document });
document.addEventListener('visibilitychange', () => {
if (document.hidden) interruptionPrompt.background();
else interruptionPrompt.foreground();
if (!document.hidden) refreshMyWorkView();
});
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
throw error;
}
return payload;
}
let commentActions = { isOwned: () => false, wire: () => {} };
function loadMentionCandidates(repository, query) {
return fetchReviewJson(
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
{headers:{Accept:'application/json'}},
);
}
const issueMentions = createMentionComposer({
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
loadCandidates:loadMentionCandidates,
});
const pullMentions = createMentionComposer({
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
loadCandidates:loadMentionCandidates,
});
const updateMentions = createMentionComposer({
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
loadCandidates:loadMentionCandidates,
});
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
async function api(url) {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Shared work request failed.');
error.unavailable = response.status === 404;
throw error;
}
return payload;
}
let reviewController = null;
let wrapPreference = null;
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
function overdueAgendaItems() {
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
}
function renderAgendaReplan() {
const state = agendaReplan.snapshot();
qs('#agenda-replan-controls').hidden = !state.active;
if (!state.active) return;
qs('#agenda-replan-progress').textContent =
'Overdue deadline ' + (state.index + 1) + ' of ' + state.total + ' · ' + (agendaReplan.current()?.key || '');
}
function openAgendaReplanCurrent() {
const item = agendaReplan.current();
if (!item) {
closeIssueSheet(false);
renderAgendaReplan();
qs('#my-work-action-status').textContent = 'Overdue sweep complete. Agenda updated.';
return;
}
renderAgendaReplan();
openIssueSheet(item, qs('#start-agenda-replan'));
}
agendaReplan = createAgendaReplan({
update: async (item, dueDate) => {
const confirmed = await issueController.updateDueDate(item, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, item.repository, item.number, confirmed.due_date
);
paintMyWork(lastContextSnapshot);
return confirmed;
},
});
qs('#start-agenda-replan').addEventListener('click', () => {
const overdue = overdueAgendaItems();
agendaReplan.start(overdue);
openAgendaReplanCurrent();
});
async function runAgendaReplan(action) {
const result = await action();
if (!result.ok) {
qs('#agenda-replan-progress').textContent = result.error + ' Current deadline retained; retry.';
return;
}
openAgendaReplanCurrent();
}
qs('#agenda-replan-keep').addEventListener('click', () => runAgendaReplan(() => agendaReplan.keep()));
qs('#agenda-replan-tomorrow').addEventListener('click', () => runAgendaReplan(() => agendaReplan.tomorrow()));
qs('#agenda-replan-choose').addEventListener('click', () =>
runAgendaReplan(() => agendaReplan.choose(qs('#agenda-replan-date').value))
);
qs('#agenda-replan-cancel').addEventListener('click', () => {
agendaReplan.cancel();
closeIssueSheet(false);
renderAgendaReplan();
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
});
const issueAttachmentController = issueAttachment.mount({
input: qs('#issue-attachment'),
preview: qs('#issue-attachment-preview'),
image: qs('#issue-attachment-image'),
meta: qs('#issue-attachment-meta'),
remove: qs('#remove-issue-attachment'),
status: qs('#issue-comment-status'),
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': payload.operation_id,
},
body: issueAttachment.multipart(payload),
},
);
},
});
const pullAttachmentController = issueAttachment.mount({
input: qs('#pull-attachment'),
preview: qs('#pull-attachment-preview'),
image: qs('#pull-attachment-image'),
meta: qs('#pull-attachment-meta'),
remove: qs('#remove-pull-attachment'),
status: qs('#pull-comment-status'),
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: { Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body: issueAttachment.multipart(payload),
},
);
},
});
const updateReplyAttachmentController = issueAttachment.mount({
input: qs('#update-reply-attachment'),
preview: qs('#update-reply-attachment-preview'),
image: qs('#update-reply-attachment-image'),
meta: qs('#update-reply-attachment-meta'),
remove: qs('#remove-update-reply-attachment'),
status: qs('#update-reply-status'),
readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.',
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => fetchReviewJson(
'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments',
{ method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body:issueAttachment.multipart(payload) },
),
});
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
preview: qs('#create-issue-attachment-preview'),
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),
remove: qs('#remove-create-issue-attachment'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
});
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
attachmentStore: unfiledAttachmentStore,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
backgroundIssueSync = createBackgroundIssueSync({
store: backgroundIssueStore, fetchJson: fetchReviewJson,
});
backgroundIssueSync.requestSync = async () => {
if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable');
const registration = await navigator.serviceWorker.ready;
if (!registration.sync) throw new Error('Background Sync unavailable');
await registration.sync.register('stackchain-issue-outbox-v1');
};
}
const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
});
const notificationReadOutbox = createNotificationReadOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => {
issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records);
notificationReadOutbox.reconcileBackground(records);
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
}
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
text: shareParams.get('text') || '',
url: shareParams.get('url') || '',
};
const commentActionFeatures = createFeatureLoader({
document,
urls: {
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
},
});
await commentActionFeatures.run('comment-actions', {
status: qs('#my-work-action-status'), retryLabel:'Reload to retry comment actions.',
}, () => {
commentActions = createCommentActions({
fetchJson: fetchReviewJson,
getLogin: () => confirmedOwnerLogin,
confirmDelete: message => window.confirm(message),
});
});
const issueCaptureFeatures = createFeatureLoader({
document,
urls: {
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
},
});
let sharedLaunchState = null;
let sharedLaunchHandled = false;
async function ensureIssueCapture() {
return await issueCaptureFeatures.run('issue-capture', {
trigger: qs('#new-issue'), status: qs('#my-work-action-status'),
}, () => {
if (!issueCapture) {
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
sharedLaunchHandled = true;
}
});
}
if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture();
const pullWorkflowFeatures = createFeatureLoader({
document,
urls: {
'pull-workflow': document.querySelector('meta[name="stackchain-feature-pull-workflow"]')?.content || '',
},
});
let pullController = null;
async function ensurePullWorkflow(trigger = null) {
return await pullWorkflowFeatures.run('pull-workflow', {
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
}, () => {
if (!pullController) {
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
const continuing = workSession.checkpointed(item);
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
closePullSheet();
if (continuing) return await completeOwnershipExitToday(item);
paintMyWork(lastContextSnapshot);
return false;
});
}
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
if (!wrapPreference) {
wrapPreference = createReviewController.createWrapPreference({
storage: localStorage,
mobile: window.matchMedia('(max-width: 600px)').matches,
});
}
});
}
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
let offlineStorageReady = await offlineWorkStore.ready();
function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status');
const retry = qs('#retry-offline-today');
const visible = offlineWorkStore.enabled() && status.total > 0;
container.hidden = !visible;
if (!visible) return;
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
(status.pending ? ' · saving ' + status.pending : '') +
(status.failed ? ' · retry ' + status.failed : '');
retry.hidden = status.failed === 0;
}
const offlineToday = createOfflineToday({
loadDetail: async item => {
if (item.is_review || item.kind === 'pull') {
if (!await ensurePullWorkflow()) throw new Error('Pull workspace is unavailable.');
return item.is_review ? reviewController.load(item) : pullController.load(item);
}
return issueController.load(item);
},
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
onStatus: renderOfflineTodayStatus,
});
function warmTodayOffline() {
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
return Promise.resolve();
}
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
}
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
onPagination: pagination => {
availablePagination = pagination;
qs('#load-more-available').hidden = !pagination.has_more;
},
onStatus: message => { qs('#find-work-status').textContent = message; },
onSelection: state => {
qs('#batch-find-work-actions').hidden = !state.active;
qs('#select-find-work').hidden = state.active;
qs('#find-work-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
qs('#claim-selected-work').disabled = state.count === 0;
renderAvailableIssues(findWorkController.items());
},
});
let findWorkSearchTimer = null;
function updateFindWorkMatchStatus() {
const query = findWorkController.query();
qs('#find-work-match-status').textContent = query ?
availablePagination.total + ' match' + (availablePagination.total === 1 ? '' : 'es') +
' for “' + query + '”.' : '';
}
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
setClock(); setInterval(setClock, 1000);
async function fetchLiveSnapshot(revisions = {}, { signal } = {}) {
const query = createContextPoller.buildRevisionQuery(revisions);
const res = await fetch('api/v1/live' + (query ? '?' + query : ''), {
headers: { Accept: 'application/json' },
signal,
});
if (!res.ok) {
const error = new Error('HTTP ' + res.status);
error.retryAfterMs = createContextPoller.retryAfterMs(res.headers.get('Retry-After'));
throw error;
}
return res.json();
}
async function markNotificationRead(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
method: 'PATCH',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
return payload;
}
async function acknowledgeNotification(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/acknowledge', { method: 'POST', headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Acknowledging the update failed.');
return payload;
}
async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
return payload;
}
async function fetchNotificationPage(page) {
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
return payload;
}
async function fetchNotificationDetail(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
return payload;
}
async function fetchNotificationConversation(notificationId, page) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/conversation?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
return payload;
}
async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
return payload;
}
async function postNotificationReply(notificationId, body, operationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({ body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
markRead: markNotificationsRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationSelection = createNotificationSelection({
limit: 50,
onChange: () => {
bulkConfirmationPending = false;
renderMyWork();
},
});
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
lastNotifications = items;
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
onPagination: pagination => {
notificationPagination = pagination;
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !pagination.has_more;
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const workPager = createWorkPager({
load: fetchWorkPage,
onItems: (stream, items) => {
if (!lastContextSnapshot) return;
if (stream === 'issue') lastContextSnapshot.issues = items;
else lastContextSnapshot.pull_requests = items;
paintMyWork(lastContextSnapshot);
},
onPagination: pagination => {
workPagination = pagination;
updateWorkPaginationControls();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
authoredOutbox,
onStatus: message => { qs('#update-reply-status').textContent = message; },
});
const updateOwnership = createUpdateOwnership({
available: () => createAndStart.available(),
claim: item => fetchReviewJson(
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number) + '/claim',
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
if (result === 'added') {
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
return result;
},
start: item => {
const outcome = createAndStart.complete(item);
if (outcome === 'started') openRoutedWork(item, qs('#update-ownership-start'));
return outcome;
},
recover: item => openRoutedWork(item, qs('#update-ownership-start')),
onClaimed: item => {
if (!lastContextSnapshot) return;
const issues = (lastContextSnapshot.issues || []).filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number
);
lastContextSnapshot = { ...lastContextSnapshot, issues:issues.concat(item) };
paintMyWork(lastContextSnapshot);
},
onState: state => {
const button = qs('#update-ownership-action');
button.hidden = state.action === 'hidden';
button.disabled = state.busy;
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
onStartState: state => {
const button = qs('#update-ownership-start');
button.hidden = state.action === 'hidden';
button.disabled = state.busy;
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
});
const notificationUndo = createDashboardNotificationUndo({
restore: requestNotificationUnread,
getItems: () => lastMyWork,
setItems: items => { lastMyWork = items; },
getNotifications: () => lastNotifications,
setNotifications: items => { lastNotifications = items; },
refresh: refreshMyWorkView,
select: qs,
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
acknowledge: acknowledgeNotification,
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
updateReplyAttachmentController.clear();
}
selectedUpdate = item;
selectedUpdateDetail = null;
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
qs('#update-comments').textContent = '';
qs('#update-conversation-status').textContent = '';
qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
qs('#open-update-gitea').href = item.url || '#';
qs('#update-reply').value = notificationReplier.loadDraft(item);
qs('#update-reply-status').textContent = '';
qs('#send-update-reply').disabled = false;
qs('#send-update-reply-read-next').disabled = false;
qs('#acknowledge-update-next').hidden = true;
qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: async detail => {
selectedUpdateDetail = detail;
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true;
},
onConversation: renderUpdateConversation,
onItems: items => {
const readId = selectedUpdate?.notification_id;
lastMyWork = items;
if (Number.isInteger(readId)) {
lastNotifications = lastNotifications.filter(item => item.id !== readId);
}
refreshMyWorkView();
},
onStatus: message => {
qs('#update-sheet-status').textContent = message;
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
if (message === 'Inbox cleared.') {
qs('#my-work-action-status').textContent = message;
showMobileQueueCompletion('Updates');
}
},
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);
},
onQueue: openWorkQueueRoute,
onClose: () => {
if (selectedIssue) closeIssueSheet(false);
if (selectedPull) closePullSheet(false);
if (selectedReview) closeReviewSheet(false);
if (selectedUpdate) closeUpdateSheet(true, false);
},
onInvalid: () => {
qs('#retry-work-route').hidden = true;
window.history.replaceState(null, '', window.location.pathname + window.location.search);
closeOpenWorkSheets();
qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.';
qs('#my-work').scrollIntoView({ block:'start' });
},
onError: () => {
qs('#my-work-action-status').textContent = 'Could not load shared work item. The link is preserved; retry when connected.';
qs('#retry-work-route').hidden = false;
},
});
workRoute.start();
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
.forEach(selector => mobileComposerViewport.close(qs(selector)));
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
qs(selector).classList.remove('open')
);
selectedIssue = null;
selectedIssueDetail = null;
selectedPull = null;
selectedPullDetail = null;
selectedReview = null;
selectedUpdate = null;
}
function openWorkSessionItem(item) {
timerView.open(workIdentity(item), workSession.checkpointed(item));
openRoutedWork(item, null, { replace:true });
}
const sessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onError: () => {
qs('#my-work-action-status').textContent =
'Session recovery could not be saved on this device. You can keep working.';
},
});
const timer = createTodayTimer({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
});
const timerView = createTodayTimerView({
timer,
isActive: () => workSession.checkpointed(),
queryAll: s => document.querySelectorAll(s),
formatEstimate: formatPlanMinutes,
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
onReopen: identity => {
selectTodayWork();
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
},
onComplete: identity => {
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
return completeTodayItem(item);
},
});
renderAttentionInterruption();
qs('#return-to-today').addEventListener('click', () => {
const returned = timer.returnFromAttention();
renderAttentionInterruption();
if (!returned) return;
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === returned.identity);
if (item) workSession.reopen(item);
else continueTodaySession();
timerView.render();
});
const interruptionPrompt = createTodayInterruptionPrompt({
timer,
sheet: qs('#today-interruption-sheet'),
description: qs('#today-interruption-description'),
getItemLabel: identity => {
const item = [...todayMyWork, ...activeMyWork].find(entry => todayWork.identity(entry) === identity);
return item?.title || '';
},
onResolved: () => timerView.render(),
});
window.addEventListener('pagehide', () => interruptionPrompt.background());
interruptionPrompt.restore();
document.querySelectorAll('[data-today-interruption]').forEach(button => {
button.addEventListener('click', () =>
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
button.textContent = active ? 'Later today & next' : 'Later today';
});
document.querySelectorAll('[data-detail-defer-preset=tomorrow]').forEach(button => {
button.textContent = active ? 'Tomorrow & next' : 'Tomorrow';
});
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
button.textContent = active ? 'Choose date & time & next' : 'Choose date & time';
});
}
function updateWorkSessionActions() {
const active = workSession.active();
qs('#start-work-session').hidden = active;
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
}
const workSession = createWorkSession({
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkMilestone,
checkpoint: sessionCheckpoint,
checkpointEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem,
onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
timerView.update(state, runway);
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
button.disabled = !state.can_previous;
});
document.querySelectorAll('[data-work-session-next]').forEach(button => {
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
});
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
button.hidden = !workSession.checkpointed();
});
},
onFinish: () => todayRecapView.finish(selectedWorkFilter),
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
let todayReadinessTrigger = null;
let todayReadinessBlockerFocus = null;
let searchPreviewReturnKind = null;
function inspectTodayDependencies(item) {
if (offlineWorkMode) {
const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim();
const detail = offlineWorkStore.loadDetail(login, item);
return Promise.resolve({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
});
}
const [owner, repo] = String(item.repository || '').split('/');
return api('api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/issues/' + encodeURIComponent(item.number) + '/detail').then(detail => ({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
}));
}
function suspendTodayReadiness() {
qs('#today-readiness-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
}
function closeTodayReadiness(navigate = true) {
suspendTodayReadiness();
if (navigate) taskOverlayHistory.close();
else {
todayReadiness.cancel();
todayReadinessBlockerFocus = null;
requestAnimationFrame(() => todayReadinessTrigger?.focus());
}
}
function renderTodayReadiness(state) {
if (!state?.target) return;
const unknown = state.status === 'unknown';
qs('#today-readiness-title').textContent = unknown ? 'Blocker status unavailable' : 'This item is blocked';
qs('#today-readiness-summary').textContent = unknown
? 'Stackchain could not confirm whether this issue is ready. Retry or choose an explicit override.'
: state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.');
qs('#today-readiness-item').innerHTML = '<strong>' + escapeHtml(state.target.title || 'Untitled issue') +
'</strong><div class="small">' + escapeHtml(state.target.repository + '#' + state.target.number) + '</div>';
const blockers = qs('#today-readiness-blockers');
blockers.innerHTML = state.dependencies.map((blocker, index) =>
'<button class="issue-blocker today-readiness-blocker" type="button" data-today-blocker-index="' + index + '"><strong>' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + ' · Preview and work blocker</span></button>'
).join('');
blockers.querySelectorAll('[data-today-blocker-index]').forEach(button => {
button.addEventListener('click', () => {
const blocker = state.dependencies[Number(button.dataset.todayBlockerIndex)];
todayReadinessBlockerFocus = blocker?.repository + '#' + blocker?.number;
todayReadiness.previewBlocker(blocker);
});
});
qs('#today-readiness-next').hidden = !state.nextReady;
if (state.nextReady) qs('#today-readiness-next').textContent = 'Start next ready · ' +
(state.nextReady.title || state.nextReady.key || 'work item');
qs('#today-readiness-retry').hidden = !unknown;
qs('#today-readiness-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => {
const blocker = state.dependencies.findIndex(candidate =>
candidate?.repository + '#' + candidate?.number === todayReadinessBlockerFocus
);
if (blocker >= 0) blockers.querySelector('[data-today-blocker-index="' + blocker + '"]')?.focus();
else (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus();
});
}
function performTodayTransition(action, item = null) {
qs('#today-readiness-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
const transitioned = ({
start: () => workSession.start(item),
resume: () => workSession.resume(item),
continue: () => workSession.reopen(item),
next: () => workSession.next(item),
complete: () => workSession.complete(item),
})[action]?.();
if (!transitioned && action !== 'next' && action !== 'complete') {
qs('#my-work-action-status').textContent = 'Saved Today item is no longer available.';
updateWorkSessionActions();
}
}
const todayReadiness = createTodayReadiness({
inspect:inspectTodayDependencies,
onOpen:performTodayTransition,
onGate:state => {
todayReadinessTrigger = document.activeElement;
renderTodayReadiness(state);
taskOverlayHistory.open('today-readiness');
},
onPreview:blocker => {
searchPreviewReturnKind = 'today-readiness';
searchPreview.open({ ...blocker, kind:'issue' }).catch(() => {});
taskOverlayHistory.open('search-preview');
},
});
function runTodayTransition(action) {
selectTodayWork();
const target = workSession.target(action);
if (!target) {
performTodayTransition(action);
return Promise.resolve('empty');
}
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
return todayReadiness.run(action, workSession.items(), target);
}
function continueTodaySession() {
return runTodayTransition('continue');
}
function resumeTodaySession() {
return runTodayTransition('resume');
}
function startTodaySession() {
return runTodayTransition('start');
}
const createAndStart = createCreateAndStart({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
start: item => {
qs('[data-work-filter="today"]').click();
workSession.start(item);
},
announce: message => { qs('#my-work-action-status').textContent = message; },
});
const queueToday = createQueueToday({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
});
const laterAndStart = createLaterAndStart({
todayWork,
todaySync,
laterWork,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
start: item => {
qs('[data-work-filter="today"]').click();
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
return todayReadiness.run('start', workSession.items(), item);
},
announce: message => { qs('#my-work-action-status').textContent = message; },
});
const selectedSessionItem = kind => ({
issue: selectedIssue,
pull: selectedPull,
review: selectedReview,
update: selectedUpdate,
})[kind] || null;
const completeTodayItem = createTodayCompletion({
todayWork,
todaySync,
workSession,
refresh: () => refreshMyWorkView({ reconcileSession:false }),
warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; },
advance: () => runTodayTransition('complete'),
});
async function completeOwnershipExitToday(item) {
if (!item || !todayWork.remove(item)) return null;
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refreshMyWorkView({ reconcileSession:false });
warmTodayOffline();
return await runTodayTransition('complete');
}
function setCommentNextVisibility(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
}
const issueCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await issueController.comment(item, body);
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const pullCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await pullController.comment(item, body);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const updateReplyReadNext = createUpdateReplyReadNext({
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
markRead: markNotificationRead,
queue: message => authoredOutbox.enqueueDurably(message),
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
canQueue: canQueueMessage,
accept: item => {
notificationReplier.saveDraft(item, '');
if (selectedUpdate === item) qs('#update-reply').value = '';
},
next: item => notificationReader.acceptReadAndNext(lastMyWork, item),
});
const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options),
});
function reviewingActiveTodayItem() {
return workSession.checkpointed();
}
function acceptClaimedIssue(confirmed) {
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
candidate.repository !== confirmed.repository || candidate.number !== confirmed.number
));
lastMyWork = buildMyWork(lastContextSnapshot);
return lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
}
const assignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: item => findWorkController.claim(item),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
queue: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
return queueToday(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#find-work'));
},
announce: message => {
qs('#find-work-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
const batchFindWork = createBatchFindWork({
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
owner:()=>planningOwnerLogin,timeBudget:()=>{
const plan = todayWork.planning();
return {
capacity_minutes: plan.capacity_minutes,
planned_minutes: Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0),
};
},
claim: item => findWorkController.claim(item),
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
persistEstimate: (confirmed, minutes) => {
const plan = todayWork.planning();
plan.estimates[todayWork.identity(confirmed)] = minutes;
todayWork.replacePlanning(plan);
todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates);
todaySync.flush();
},
onProgress: progress => {
if (progress.status === 'full') {
qs('#find-work-status').textContent = 'Today has ' + progress.available +
' open slot' + (progress.available === 1 ? '' : 's') + '. Reduce the selection before assigning.';
} else if (progress.status === 'estimates-required') {
qs('#find-work-estimate-summary').textContent = 'Add an estimate for every issue.';
} else if (progress.status === 'over-budget') {
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(progress.over_minutes) +
' over Todays remaining time. Reduce an estimate or selection.';
} else if (progress.status === 'running') {
qs('#find-work-status').textContent = 'Assigning and queueing ' + progress.processed +
' of ' + progress.selected + '…';
}
},
});
function findWorkEstimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-find-work-estimate]')).map(input =>
[input.dataset.findWorkEstimate, Number(input.value)]
));
}
function closeFindWorkEstimateReview() {
qs('#find-work-estimate-review').hidden = true;
qs('#batch-find-work-actions').hidden = !findWorkController.selection().active;
}
function openFindWorkEstimateReview(items) {
const plan = todayWork.planning();
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
' remaining. Estimate selected work before assigning it.';
qs('#find-work-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) +
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label></div>';
}).join('');
qs('#batch-find-work-actions').hidden = true;
qs('#find-work-estimate-review').hidden = false;
qs('[data-find-work-estimate]')?.focus();
}
let planTodayTrigger = null;
function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set';
const absolute = Math.abs(minutes);
const hours = Math.floor(absolute / 60);
const remainder = absolute % 60;
return [hours ? hours + 'h' : '', remainder ? remainder + 'm' : ''].filter(Boolean).join(' ') || '0m';
}
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
const title = escapeHtml(item.title || 'Untitled work');
const state = planToday.snapshot();
const estimate = state.estimates?.[id] || '';
const recommendation = state.recommendations?.[id];
const estimateControl = selected ? '<label class="small plan-today-estimate-wrap">Estimate <input class="plan-today-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" value="' + escAttr(estimate) + '" data-plan-estimate="' + escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || key) + ' in minutes" /> min</label>' : '';
const recommendationControl = selected && recommendation ? '<button class="plan-today-recommendation" type="button" data-plan-recommendation="' +
escAttr(id) + '">Use ' + formatPlanMinutes(recommendation) + ' as new estimate</button>' : '';
const controls = selected ?
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === state.count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
(item.kind === 'issue' ?
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview to add</button></div>' :
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>');
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + recommendationControl + '</div>' + controls + '</article>';
}
function resetPlanTodayConfirmation() {
const save = qs('#save-today-plan');
const start = qs('#save-and-start-today');
save.dataset.confirmOverCapacity = '';
start.dataset.confirmOverCapacity = '';
save.textContent = 'Save plan';
start.textContent = 'Save & start';
}
function renderPlanToday() {
resetPlanTodayConfirmation();
const state = planToday.snapshot();
const capacityText = state.capacity_minutes === null ? 'Set available time to check fit' :
(state.over_capacity ? formatPlanMinutes(-state.remaining_minutes) + ' over capacity' :
formatPlanMinutes(state.remaining_minutes) + ' free');
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected · Planned ' +
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
qs('#plan-today-available').value = state.capacity_minutes || '';
const build = state.build;
qs('#plan-today-build-status').textContent = build ?
('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') +
(build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') +
(build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : '';
const estimateSection = qs('#plan-today-estimates');
estimateSection.hidden = !build?.needs_estimate.length;
qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
const item = planToday.item(id);
const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
return '<div class="plan-today-estimate-row"><div><span class="small">' + key + '</span><strong class="my-work-card-title">' +
escapeHtml(item?.title || 'Untitled work') + '</strong></div><label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-plan-missing-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item?.title || key) + ' in minutes" /> min</label></div>';
}).join('');
qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
'<div class="small plan-today-skipped-item"><strong>' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
'</strong> · ' + escapeHtml(entry.reason) + '</div>'
).join('');
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : '<p class="muted">No work selected yet.</p>';
const candidates = planToday.candidates();
qs('#plan-today-candidates').innerHTML = candidates.length ? candidates.map(item =>
planTodayItemMarkup(item, false)
).join('') : '<p class="muted">All available work is already selected.</p>';
document.querySelectorAll('[data-plan-add]').forEach(button => button.addEventListener('click', () => {
const result = planToday.toggle(planToday.item(button.dataset.planAdd));
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-preview]').forEach(button => button.addEventListener('click', () => {
const item = planToday.item(button.dataset.planPreview);
if (!canPreviewPlanItem(item)) {
qs('#plan-today-error').textContent = 'Preview unavailable offline. Reconnect or add the item without previewing it.';
return;
}
qs('#plan-today-error').textContent = '';
if (planTodayPreview.open(item, button)) taskOverlayHistory.open('plan-today-preview');
}));
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
planToday.toggle(planToday.item(button.dataset.planRemove));
qs('#plan-today-error').textContent = '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-move]').forEach(button => button.addEventListener('click', () => {
planToday.move(button.dataset.planId, button.dataset.planMove);
renderPlanToday();
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
}));
document.querySelectorAll('[data-plan-estimate]').forEach(input => input.addEventListener('change', () => {
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
const id = input.dataset.planMissingEstimate;
if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
input.focus();
return;
}
qs('#plan-today-error').textContent = '';
renderPlanToday();
document.querySelector('[data-plan-missing-estimate]')?.focus();
}));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
document.querySelector('[data-plan-estimate="' + CSS.escape(button.dataset.planRecommendation) + '"]')?.focus();
}));
}
function closePlanToday(navigate = true) {
planTodayReadiness.cancel();
qs('#build-today-plan').disabled = false;
qs('#plan-today-build-status').textContent = '';
if (navigate) {
taskOverlayHistory.close();
return;
}
planToday.cancel();
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
planTodayTrigger?.focus();
}
function saveTodayPlan(plan) {
const capacityAware = !Array.isArray(plan);
const ids = capacityAware ? plan.ids : plan;
const previous = todayWork.read();
if (rolloverReviewPlan) {
const operation = todayRollover.operation({
operation_id: 'pending', base_revision: rolloverReviewPlan.revision,
selected_ids: ids, capacity_minutes: plan.capacity_minutes, estimates: plan.estimates,
});
if (!todaySync.enqueueRollover(operation)) return false;
rolloverReviewPlan = null;
qs('#plan-today').textContent = 'Plan Today';
qs('#plan-today-title').textContent = 'Plan Today';
} else {
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
}
if (!todayWork.replace(ids)) return false;
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
todayRecapView.completeReplan();
refreshMyWorkView();
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order and available time.' : 'Today plan cleared.';
return true;
}
const planToday = createPlanToday({
identity: item => todayWork.identity(item),
limit: todayWork.limit,
save: saveTodayPlan,
start: () => {
qs('[data-work-filter="today"]').click();
startTodaySession();
},
});
const planTodayReadiness = createPlanTodayReadiness({
concurrency:3,
identity:item => todayWork.identity(item),
inspect:async item => {
if (item.kind !== 'issue') return { status:'ready' };
const detail = await inspectTodayDependencies(item);
if (!detail.available) return { status:'unverified' };
if (detail.dependencies.length) return {
status:'blocked', reason:detail.dependencies.length + ' open ' +
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
};
return { status:'ready' };
},
});
async function buildTodayPlan() {
const button = qs('#build-today-plan');
const state = planToday.snapshot();
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
button.disabled = true;
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
const checks = await planTodayReadiness.run(candidates);
if (!checks) return;
planToday.buildRecommendation(checks);
button.disabled = false;
renderPlanToday();
}
function canPreviewPlanItem(item) {
if (!item || !offlineWorkMode) return Boolean(item);
const login = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
return Boolean(offlineWorkStore.loadDetail(login, item));
}
function openPlanPreviewDetail(item, trigger) {
qs('#plan-today-sheet').hidden = true;
qs('#plan-preview-actions').hidden = false;
if (item.kind === 'issue') renderPlanIssueDependencies(null, true);
if (offlineWorkMode) {
openRoutedWork(item, trigger);
} else if (item.kind === 'update' && item.has_update) {
updateTrigger = trigger;
notificationReader.open(item, lastMyWork);
} else if (item.is_review || item.kind === 'review') {
reviewTrigger = trigger;
openReviewSheet(item, trigger);
} else if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger);
} else if (item.kind === 'pull') {
pullTrigger = trigger;
openPullSheet(item, trigger);
}
}
const planTodayPreview = createPlanTodayPreview({
planner: planToday,
identity: item => todayWork.identity(item),
getScroll: () => qs('.plan-today-panel').scrollTop,
setScroll: value => requestAnimationFrame(() => { qs('.plan-today-panel').scrollTop = value; }),
onOpen: openPlanPreviewDetail,
onClose: (_item, trigger) => {
closeOpenWorkSheets();
qs('#plan-preview-actions').hidden = true;
qs('#issue-blockers').hidden = true;
qs('#issue-blocker-list').textContent = '';
qs('#add-plan-preview').disabled = false;
qs('#add-plan-preview').textContent = 'Add to Today & back';
qs('#plan-today-sheet').hidden = false;
renderPlanToday();
requestAnimationFrame(() => trigger?.focus());
},
});
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
const status = qs('#issue-blocker-status');
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
qs('#manage-issue-blockers').hidden = !assignedIssue;
qs('#start-unblocked-issue').hidden = true;
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
if (assignedIssue) {
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
list.innerHTML = dependencies.map((blocker, index) =>
'<div class="issue-blocker-row"><a class="issue-blocker" href="' + escAttr(blocker.url || '#') +
'" target="_blank" rel="noopener noreferrer"><strong>' + escapeHtml(blocker.repository + '#' + blocker.number) +
' · ' + escapeHtml(blocker.title || 'Untitled blocker') + '</strong><span class="small">State: ' +
escapeHtml(blocker.state || 'open') + '</span></a><button class="issue-blocker-remove" type="button" data-remove-blocker-index="' +
index + '">Remove blocker</button></div>'
).join('');
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
button.addEventListener('click', () => {
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
mutateIssueBlocker(blocker, true, button);
}
});
});
status.textContent = available ? (dependencies.length ?
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
'No unresolved blockers. Add one without leaving this issue.') :
'Blocker status unavailable. Reload before changing relationships.';
qs('#manage-issue-blockers').disabled = !available;
return;
}
addButton.disabled = loading;
addButton.dataset.planOverride = '';
if (loading) {
status.textContent = 'Checking unresolved blockers…';
addButton.textContent = 'Checking blockers…';
return;
}
const available = detail?.dependencies_available === true;
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
planTodayPreview.setDependencies({ available, dependencies });
if (!available) {
status.textContent = 'Blocker status unavailable. Adding requires an explicit override.';
addButton.textContent = 'Add without blocker status anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
if (dependencies.length) {
list.innerHTML = dependencies.map(blocker =>
'<a class="issue-blocker" href="' + escAttr(blocker.url || '#') + '" target="_blank" rel="noopener noreferrer"><strong>' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + '</span></a>'
).join('');
status.textContent = dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.');
addButton.textContent = 'Add blocked item anyway & back';
addButton.dataset.planOverride = 'true';
return;
}
panel.hidden = true;
status.textContent = '';
addButton.textContent = 'Add to Today & back';
}
async function mutateIssueBlocker(blocker, remove, button) {
if (!selectedIssue) return;
const item = selectedIssue;
button.disabled = true;
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
try {
if (selectedIssueOffline) {
await queueOfflineIssueBlocker(item, blocker, !remove);
if (selectedIssue !== item) return;
button.textContent = 'Queued';
qs('#issue-blocker-status').textContent = remove ?
'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
return { queued:true };
}
const result = remove ?
await issueController.updateBlocker(selectedIssue, blocker, true) :
await issueController.updateBlocker(selectedIssue, blocker, false);
if (selectedIssue !== item) return;
selectedIssueDetail = { ...selectedIssueDetail, ...result };
renderPlanIssueDependencies(selectedIssueDetail);
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
qs('#start-unblocked-issue').hidden = false;
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
button.disabled = false;
button.focus();
}
}
function renderIssueBlockerCandidates(items) {
issueBlockerCandidates = items;
const results = qs('#issue-blocker-results');
results.innerHTML = items.map((result, index) =>
'<button type="button" role="option" data-blocker-result="' + index + '"><strong>' +
escapeHtml(result.repository + '#' + result.number) + '</strong><span class="small">' +
escapeHtml(result.title || 'Untitled issue') + '</span></button>'
).join('');
results.querySelectorAll('[data-blocker-result]').forEach(button => {
button.addEventListener('click', () => {
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
candidate.repository === blocker.repository && candidate.number === blocker.number
)) {
qs('#issue-blocker-manager').hidden = true;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
}
});
});
});
}
qs('#manage-issue-blockers').addEventListener('click', () => {
qs('#issue-blocker-manager').hidden = false;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
qs('#issue-blocker-search').focus();
});
qs('#cancel-issue-blocker').addEventListener('click', () => {
qs('#issue-blocker-manager').hidden = true;
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
qs('#manage-issue-blockers').focus();
});
qs('#issue-blocker-search').addEventListener('keydown', event => {
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
});
qs('#issue-blocker-search').addEventListener('input', event => {
clearTimeout(issueBlockerSearchTimer);
const query = event.target.value.trim();
if (query.length < 2) {
renderIssueBlockerCandidates([]);
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
return;
}
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
issueBlockerSearchTimer = setTimeout(async () => {
try {
const candidates = selectedIssueOffline ? workSession.items() :
(await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
const existing = selectedIssueDetail?.dependencies || [];
const normalizedQuery = query.toLowerCase();
const items = (candidates || []).filter(result =>
result.kind === 'issue' && result.state === 'open' &&
(!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
.toLowerCase().includes(normalizedQuery)) &&
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
);
renderIssueBlockerCandidates(items);
qs('#issue-blocker-search-status').textContent = items.length ?
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
} catch (error) {
renderIssueBlockerCandidates([]);
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
}
}, 250);
});
qs('#start-unblocked-issue').addEventListener('click', () => {
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
});
let addPlanPreviewOnReturn = false;
let addPlanPreviewOverride = false;
qs('#back-to-plan').addEventListener('click', () => {
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
taskOverlayHistory.close();
});
qs('#add-plan-preview').addEventListener('click', () => {
addPlanPreviewOnReturn = true;
addPlanPreviewOverride = qs('#add-plan-preview').dataset.planOverride === 'true';
taskOverlayHistory.close();
});
let pendingPlanActualMinutes = null;
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
if (trigger) planTodayTrigger = trigger;
qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today';
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
if (navigate) {
taskOverlayHistory.open('plan-today');
return;
}
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
pendingPlanActualMinutes = null;
planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations);
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
renderPlanToday();
qs('#cancel-plan-today').focus();
}
qs('#discard-recap-replan').addEventListener('click', () => {
todayRecapView.discardReplan();
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
qs('#discard-recap-replan').hidden = true;
qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.';
renderPlanToday();
});
const detailDefer = createDetailDefer({
laterWork,
session: workSession,
continueSession: item => completeTodayItem(item, {
successMessage: 'Deferred to Later. Next Today item opened.',
failureMessage: 'Could not remove this item from Today.',
}),
close: () => workRoute.close(),
refresh: refreshMyWorkView,
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
announce: message => { qs('#my-work-action-status').textContent = message; },
formatTime: fmt,
});
const laterPickerElement = qs('#later-picker');
const laterPickerInput = qs('#later-picker-time');
const laterPicker = createLaterPicker({
history: window.history,
eventTarget: window,
onState: state => {
qs('#later-picker-error').textContent = state.message || '';
if (state.open) {
laterPickerInput.value = state.value || laterPickerInput.value;
qs('#later-picker-timezone').textContent = 'Times use ' +
(Intl.DateTimeFormat().resolvedOptions().timeZone || 'your device timezone') + '.';
laterPickerElement.hidden = false;
if (!laterPickerElement.open) laterPickerElement.showModal();
laterPickerInput.focus();
} else {
if (laterPickerElement.open) laterPickerElement.close();
laterPickerElement.hidden = true;
}
},
onConfirm: (item, until, context) => {
if (context === 'detail') {
const inSession = workSession.active();
const deferred = detailDefer.deferUntil(item, until, {
closeSheet:false,
restoreFocus:false,
});
if (!deferred) return false;
return inSession ? true : () => workRoute.close();
}
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return false;
refreshMyWorkView();
return true;
},
});
laterPicker.start();
qs('#later-picker-form').addEventListener('submit', event => {
event.preventDefault();
laterPicker.submit(laterPickerInput.value);
});
qs('#cancel-later-picker').addEventListener('click', () => laterPicker.close());
laterPickerElement.addEventListener('cancel', event => {
event.preventDefault();
laterPicker.close();
});
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
detailDefer.defer(item, button.dataset.detailDeferPreset);
});
});
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
laterPicker.open(item, button, 'detail');
});
});
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
button.addEventListener('click', () => {
const chooser = button.closest('.detail-defer');
chooser.open = false;
chooser.querySelector('summary')?.focus();
});
});
function updatePlanningAvailability() {
document.querySelectorAll('[data-detail-defer-preset], [data-detail-defer-custom]').forEach(button => {
button.disabled = !planningOwnerLogin;
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
});
}
function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [
'#edit-issue-content', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
] : createPullSheet.ownershipSelectors().concat([
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
]);
selectors.forEach(selector => {
const control = qs(selector);
if (control) control.disabled = true;
});
if (kind === 'issue') {
qs('#issue-planning').inert = true;
qs('#issue-handoff').inert = true;
} else {
qs('#pull-review').inert = true;
qs('#pull-ownership').inert = true;
}
}
function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#acknowledge-update-next').disabled = offline;
qs('#update-ownership-action').disabled = offline;
qs('#update-ownership-start').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline;
}
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
const merge = (older, latest) => {
const byId = new Map((older || []).map(item => [item.id, item]));
(latest || []).forEach(item => {
const current = byId.get(item.id) || {};
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
byId.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
return Array.from(byId.values());
};
data.issues = merge(lastContextSnapshot.issues, data.issues);
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
}
lastContextSnapshot = data;
if (data.work_pagination) workPager.reset(data.work_pagination);
if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data);
openStagedSharedContent();
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
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 => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
const prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
paintDeltas(data.deltas || []);
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
renderRepoMix(qs('#repo-mix'), data);
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
setClock();
}
async function handleContextError(e) {
console.error('context failed', e);
liveMode = false;
activeFlushLogin = '';
if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;
const timeoutStatus = e.name === 'TimeoutError' ?
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
if (!hasContextSnapshot) {
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
qs('#view-hint').textContent = 'Active view unavailable.';
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
paintDeltas([]);
}
markMyWorkStale();
}
function paintMyWork(data) {
lastMyWork = buildMyWork(data);
refreshMyWorkView();
}
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
}));
return draftInbox.list().concat(unfiled).sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0)
);
}
function refreshMyWorkView({ reconcileSession = true } = {}) {
lastDrafts = listDrafts();
const partitioned = laterWork.partition(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
activeMyWork = partitioned.active;
laterMyWork = partitioned.later;
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
!lastContextSnapshot?.error &&
!Object.values(workPagination).some(page => page?.has_more);
todayMyWork = todayWork.reconcile(lastMyWork, {
pruneMissing: authoritativeTodayReconciliation,
onPrune: retiredIds => {
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
if (queued) todaySync.flush();
return queued;
},
});
const counts = countMyWork(activeMyWork);
const agendaItems = agendaMyWork(activeMyWork);
counts.agenda = agendaItems.length;
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,
agenda: counts.agenda,
});
launchFilterResolved = true;
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item.dataset.workFilter === selectedWorkFilter))
);
}
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
mobileQueueCounts = counts;
mobileTaskDock.updateQueues(counts);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
' (' + (counts[selectedWorkFilter] || 0) + ')';
const milestoneSelect = qs('#work-milestone-filter');
const lanes = milestoneLanes(lastMyWork);
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
'<option value="unplanned">Unplanned</option>' + lanes.map(lane =>
'<option value="' + Number(lane.id) + '">' + escapeHtml(lane.title) + '</option>'
).join('');
milestoneSelect.value = selectedWorkMilestone;
if (milestoneSelect.value !== selectedWorkMilestone) {
const retained = document.createElement('option');
retained.value = selectedWorkMilestone;
retained.textContent = 'Selected milestone';
milestoneSelect.append(retained);
milestoneSelect.value = selectedWorkMilestone;
}
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
if (selectedWorkFilter === 'agenda' && workPagination.issue?.has_more && !agendaChecking) {
completeAgendaIssues();
}
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
}
function activeWorkStreams() {
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'agenda') return ['issue'];
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 [];
}
async function completeAgendaIssues() {
if (selectedWorkFilter !== 'agenda' || !workPagination.issue?.has_more || !lastContextSnapshot) return;
agendaChecking = true;
qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…';
renderMyWork();
const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []);
agendaChecking = false;
if (selectedWorkFilter !== 'agenda') return;
qs('#my-work-action-status').textContent = complete ?
'All assigned deadlines checked.' :
'Agenda check paused. Retry to check older assigned deadlines.';
renderMyWork();
}
function renderDrafts() {
const list = qs('#my-work-list');
const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
const deliveryCenter = draftInbox.partition(displayedDrafts);
const renderDraftCard = item => {
const index = lastDrafts.indexOf(item);
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue';
const reviewOutbox = item.outbox_kind === 'pull-review';
const closureOutbox = item.outbox_kind === 'issue-close';
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
item.continuation ?
'<button class="draft-continue" data-draft-index="' + index + '" type="button">Continue created work</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Dismiss</button>' :
item.kind === 'issue-outbox' ?
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
reviewOutbox && item.status === 'authorization' ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize &amp; send review</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
reviewOutbox && item.status === 'attention' ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
item.kind === 'authored-outbox' && closureOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open issue</button>' +
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize &amp; close</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
item.kind === 'authored-outbox' && !reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = (isOutbox || isUnfiled) ?
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '</span>' +
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
'<div class="draft-actions">' + outboxActions + '</div></article>';
};
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
'Authorize <strong data-delivery-count="authorization">' + deliveryCenter.counts.authorization + '</strong></p></div>' +
'<button id="retry-waiting-deliveries" type="button"' +
(deliveryCenter.retryable.length && activeFlushLogin ? '' : ' disabled') + '>Retry waiting</button></section>';
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
'<div class="muted">No queued deliveries.</div>';
const draftCards = deliveryCenter.drafts.length ? deliveryCenter.drafts.map(renderDraftCard).join('') :
'<div class="muted">No unfinished drafts.</div>';
list.innerHTML = deliverySummary + '<section class="draft-section" aria-label="Queued deliveries">' +
'<h3>Queued deliveries</h3>' + deliveryCards + '</section>' +
'<section class="draft-section" aria-label="Unfinished drafts"><h3>Unfinished drafts</h3>' + draftCards + '</section>';
updateQueueFinder(displayedDrafts.length, lastDrafts.length, false);
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
const button = event.currentTarget;
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
applyOutboxResult(issueResult);
applyAuthoredOutboxResult(authoredResult);
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
dFS.start(item.capture_id);
await dFS.nextCapture();
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
});
});
list.querySelectorAll('.draft-continue').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const completion = issueOutbox.pendingCompletions(activeFlushLogin)
.find(candidate => candidate.id === item?.outbox_id);
if (completion) applyOutboxResult({
confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(),
});
});
});
list.querySelectorAll('.draft-edit').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!queued) return;
button.disabled = true;
try {
const hydrated = await issueOutbox.hydrateForEdit(queued.id);
if (!hydrated) return;
editingOutboxId = hydrated.id;
issueCapture.saveDraft(hydrated);
openCreateIssueSheet();
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment);
else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
} catch (error) {
qs('#my-work-action-status').textContent = String(error?.message ||
'The saved screenshot could not be loaded. Retry before editing this issue.');
} finally {
button.disabled = false;
}
});
});
list.querySelectorAll('.draft-send').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
});
});
list.querySelectorAll('.draft-authorize').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id || !activeFlushLogin) return;
button.disabled = true;
const reviewAuthorization = item.outbox_kind === 'pull-review';
qs('#my-work-action-status').textContent = reviewAuthorization ?
'Fresh authorization required for this exact review decision and head.' :
'Fresh authorization required for this exact issue.';
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(result);
qs('#my-work-action-status').textContent = result.confirmed?.length ?
(reviewAuthorization ? 'Review submitted and queued intent cleared.' :
'Issue closed and queued intent cleared.') :
(reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
'Issue closure was not confirmed. The queued intent is still safe.');
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.copy_text) return;
await navigator.clipboard.writeText(item.copy_text);
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
lastDrafts = listDrafts();
const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length;
renderDrafts();
qs('#my-work-action-status').textContent = 'Draft discarded.';
});
});
}
function updateWorkPaginationControls() {
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
const streams = activeWorkStreams();
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
const summaries = streams.flatMap(stream => {
const page = workPagination[stream];
return page && page.total ?
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
});
qs('#work-page-status').textContent = summaries.join(' · ');
qs('#load-more-work').hidden = incomplete.length === 0;
qs('#load-more-work').textContent = incomplete.length ?
'Load older ' + labels[incomplete[0]] : 'Load older work';
}
function renderMyWork() {
lastDrafts = listDrafts();
const draftCount = qs('[data-work-count="draft"]');
if (draftCount) draftCount.textContent = lastDrafts.length;
if (selectedWorkFilter === 'draft') {
renderDrafts();
qs('#bulk-mark-read-bar').hidden = true;
qs('#load-more-notifications').hidden = true;
return;
}
const queueItems = selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
agendaMyWork(activeMyWork) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const overdue = queueItems.filter(item => item.agenda_group === 'Overdue');
const replanPanel = qs('#agenda-replan');
replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0;
if (!agendaReplan?.snapshot().active) {
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
qs('#agenda-replan-controls').hidden = true;
}
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
const visible = findQueueItems(queueItems, queueFindQuery);
const emptyWorkStart = qs('#empty-work-start');
const showEmptyStart = selectedWorkFilter === 'all' && !queueFindQuery &&
!queueItems.length && !incomplete && hasContextSnapshot;
emptyWorkStart.hidden = !showEmptyStart;
updateQueueFinder(visible.length, queueItems.length, incomplete);
const selection = notificationSelection.snapshot();
const selectedIds = new Set(selection.ids);
const workSelectionState = workSelection.snapshot();
const selectedWorkIds = new Set(workSelectionState.ids);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const routeItem = routedWorkItem(item);
const routeHref = createWorkRoute.serialize(routeItem);
const contents =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
(item.agenda_group ? '<span class="pill agenda-badge" data-agenda-group="' + escAttr(item.agenda_group) + '">' + escapeHtml(item.agenda_group) + '</span>' : '') +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const selectable = selectedWorkFilter === 'update' && selection.active &&
item.has_update && Number.isInteger(item.notification_id);
const workSelectable = workSelectionState.active;
const workId = workSelection.identity(item);
const selector = workSelectable ?
'<label class="work-selector"><input type="checkbox" data-select-work-id="' + escAttr(workId) + '" data-work-index="' + index + '" aria-label="Select work ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedWorkIds.has(workId) ? ' checked' : '') + '> Select</label>' : (selectable ?
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '');
const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') +
((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
const laterActions = selectedWorkFilter === 'later' ?
'<div class="later-actions" aria-label="Deferred work actions"><button type="button" data-later-start data-work-index="' + index + '"' + planningDisabled + '>Start now</button><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button><button type="button" data-later-custom data-work-index="' + index + '"' + planningDisabled + '>Choose date &amp; time</button></div>';
const alreadyToday = todayWork.contains(item);
const todayPosition = todayWork.position(item);
const todayActions = selectedWorkFilter === 'today' ?
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
const planningActions = selectedWorkFilter === 'later' ? laterActions :
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
if (item.is_review) {
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
if (item.kind === 'issue') {
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
if (item.kind === 'pull') {
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
}).join('') : (showEmptyStart ? '' : '<div class="muted">' +
(selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ?
(agendaChecking ? 'Checking all assigned deadlines…' :
'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.')) + '</div>');
cardPlanning.wire();
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
input.addEventListener('change', () => {
const notificationId = Number(input.dataset.selectNotificationId);
const result = input.checked ? notificationSelection.select(notificationId) :
notificationSelection.toggle(notificationId);
if (result === 'limit') {
input.checked = false;
qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
}
});
});
document.querySelectorAll('[data-select-work-id]').forEach(input => {
input.addEventListener('change', () => {
const item = lastMyWork[Number(input.dataset.workIndex)];
const result = input.checked ? workSelection.select(item) : workSelection.toggle(item);
if (result === 'limit') {
input.checked = false;
qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.';
}
});
});
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
document.querySelectorAll('[data-issue-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.issueIndex)], button); });
});
document.querySelectorAll('[data-pull-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.pullIndex)], button); });
});
document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', event => {
event.preventDefault();
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
openRoutedWork(item, button);
});
});
document.querySelectorAll('[data-notification-id]').forEach(button => {
button.addEventListener('click', async () => {
const notificationId = Number(button.dataset.notificationId);
button.disabled = true;
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
if (acknowledged) {
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
} else {
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
}
});
});
document.querySelectorAll('[data-later-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
const until = laterWork.presetUntil(button.dataset.laterPreset);
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return;
refreshMyWorkView();
});
});
document.querySelectorAll('[data-later-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
laterPicker.open(item, button, 'card');
});
});
document.querySelectorAll('[data-later-restore]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item || !laterWork.restore(item)) return;
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-later-start]').forEach(button => {
button.addEventListener('click', async () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item) return;
button.disabled = true;
await laterAndStart.start(item);
if (button.isConnected) button.disabled = false;
});
});
document.querySelectorAll('[data-today-add]').forEach(button => {
button.addEventListener('click', () => {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
qs('#my-work-action-status').textContent = result === 'full' ?
'Today is limited to 5 items. Remove one before adding more.' :
(result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView();
if (result === 'added') {
const item = lastMyWork[Number(button.dataset.workIndex)];
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.remove(item)) {
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
}
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.move(item, button.dataset.todayMove)) {
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
todaySync.flush();
}
refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
});
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
const updateIds = notificationIds(visible);
const selectionControls = qs('#update-selection-controls');
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
qs('#cancel-work-selection').hidden = !workSelectionState.active;
const selectionScopeActions = qs('#selection-scope-actions');
selectionScopeActions.hidden = !workSelectionState.active;
qs('#select-matching-work').disabled = visible.length === 0 || workSelectionState.count >= workSelection.limit;
qs('#clear-work-selection').disabled = workSelectionState.count === 0;
qs('#select-matching-work').onclick = () => {
const result = workSelection.selectMany(visible);
qs('#my-work-action-status').textContent = result.status === 'limit' ?
result.count + ' matches selected. Selection is capped at ' + result.limit + ' items.' :
result.count + ' matches selected from the current queue and search.';
};
qs('#clear-work-selection').onclick = () => {
workSelection.clear();
qs('#my-work-action-status').textContent = 'Selection cleared. Choose new matches or individual work.';
qs('#select-matching-work').focus();
};
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
qs('#cancel-update-selection').hidden = !selection.active;
qs('#update-selection-status').textContent = workSelectionState.active ?
workSelectionState.count + ' selected' : (selection.active ?
selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.');
bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active);
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending || selection.count === 0;
document.querySelectorAll('[data-defer-selected]').forEach(button => {
button.hidden = workSelectionState.active;
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
});
qs('#batch-add-today').hidden = !workSelectionState.active;
qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin;
document.querySelectorAll('[data-batch-defer]').forEach(button => {
button.hidden = !workSelectionState.active;
button.disabled = workSelectionState.count === 0 || !planningOwnerLogin;
});
bulkButton.hidden = workSelectionState.active;
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + selection.count + ' selected read' :
'Mark ' + selection.count + ' selected read';
workRoute.setItems(lastMyWork);
}
function reviewFileElement(filename) {
return Array.from(document.querySelectorAll('.review-file')).find(
element => element.dataset.reviewFilename === filename
);
}
function applyReviewWrap(snapshot = wrapPreference.snapshot()) {
const reviewFilesElement = qs('#review-files');
const button = qs('#review-wrap-lines');
reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped);
button.setAttribute('aria-pressed', String(snapshot.wrapped));
button.textContent = snapshot.wrapped ? 'Lines wrapped' : 'Wrap lines';
}
function showReviewProgress(snapshot) {
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
qs('#review-progress').textContent = complete ?
'All files reviewed — finish in Gitea' :
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
document.querySelectorAll('.review-mark').forEach(button => {
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
button.setAttribute('aria-pressed', String(reviewed));
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
});
}
function openNextUnreviewed(snapshot) {
if (!snapshot?.nextFilename) return;
const file = reviewFileElement(snapshot.nextFilename);
if (!file) return;
const toggle = file.querySelector('.review-file-toggle');
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
createReviewController.toggleDiff(toggle, panel);
}
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
toggle?.focus();
}
function renderIssueComment(comment) {
const actions = commentActions.actionHtml?.(comment) || '';
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function renderIssueConversation(state) {
const comments = state?.comments || [];
qs('#issue-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
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('') : '<div class="muted">No comments yet.</div>';
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 =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
qs('#pull-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function commentSurface(selector) {
if (selector === '#issue-comments') return {
context:{kind:'issue',item:selectedIssue}, pager:issueConversation,
render:renderIssueConversation, status:qs('#issue-sheet-status'),
};
if (selector === '#pull-comments') return {
context:{kind:'pull',item:selectedPull}, pager:pullConversation,
render:renderPullConversation, status:qs('#pull-sheet-status'),
};
return {
context:{kind:'update',item:selectedUpdate}, pager:notificationReader.commentPager(),
render:renderUpdateConversation, status:qs('#update-sheet-status'),
};
}
function wireCommentActions(selector) {
commentActions.wire({
root:qs(selector), getSurface:()=>commentSurface(selector),
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
});
}
wireCommentActions('#issue-comments');
wireCommentActions('#pull-comments');
wireCommentActions('#update-comments');
function renderIssueLabelEditor(item, confirmedNames, labels) {
const list = qs('#issue-label-list');
const status = qs('#issue-label-status');
list.textContent = '';
const draftIds = issueController.loadLabelDraft(item);
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
);
list.innerHTML = labels.map(label =>
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></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 = '<option value="">No milestone</option>';
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
const draft = issueController.loadMilestoneDraft(item);
select.value = String(draft ?? confirmedMilestone?.id ?? '');
select.disabled = false;
qs('#save-issue-milestone').disabled = false;
status.textContent = confirmedMilestone ?
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
}
async function loadIssuePlanning() {
const item = selectedIssue;
const detail = selectedIssueDetail;
if (!item || !detail) return;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-milestone-status').textContent = 'Loading milestones…';
try {
const planning = await planningLoader.open(selectedIssue);
if (selectedIssue !== item) return;
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
} catch (_error) {
if (selectedIssue !== item) return;
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
qs('#retry-issue-planning').hidden = false;
}
}
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
issueMentions.dismiss();
selectedIssueOffline = Boolean(offlineDetail);
selectedIssueDetail = null;
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
qs('#issue-sheet-key').textContent = item.key || '';
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
qs('#issue-sheet-status').textContent = 'Loading issue…';
qs('#issue-sheet-body').textContent = '';
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
qs('#load-older-issue-comments').hidden = true;
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#issue-handoff').open = false;
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#issue-handoff-recipient').disabled = true;
qs('#confirm-issue-handoff').disabled = true;
qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-issue-handoff').disabled = false;
qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
qs('#save-issue-due-date').disabled = true;
qs('#clear-issue-due-date').disabled = true;
qs('#issue-due-status').textContent = 'Loading due date…';
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
qs('#issue-milestone').disabled = true;
qs('#save-issue-milestone').disabled = true;
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
setCommentNextVisibility('issue');
qs('#edit-issue-content').disabled = true;
qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = '';
qs('#close-issue').disabled = false;
qs('#close-issue').textContent = offlineDetail ?
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
(workSession.active() ? 'Close & next' : 'Close issue');
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#close-issue-sheet').focus();
try {
const detail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
renderPlanIssueDependencies(detail);
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
renderIssueConversation(issueConversation.snapshot());
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
qs('#issue-due-date').disabled = false;
qs('#save-issue-due-date').disabled = false;
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
if (offlineDetail) {
setOfflineDetailControls('issue');
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#retry-issue-load').focus();
}
}
function closeIssueSheet(navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueAttachmentController.clear();
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueOffline = false;
selectedIssueDetail = null;
issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
function renderCheckSection(prefix, detail, offline = false) {
const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline });
qs('#' + prefix + '-checks-summary').textContent = rendered.summary;
qs('#' + prefix + '-check-list').innerHTML = rendered.html || '<div class="muted">No individual checks were reported.</div>';
qs('#' + prefix + '-checks').open = rendered.expanded;
}
function renderPullReview(detail, focusFilename = null) {
renderCheckSection('pull', detail);
pullReviewState = pullController.reviewState(selectedPull, detail);
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml)
).join('') : '<div class="muted">No changed files reported.</div>';
qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => {
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls'));
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
if (panel) panel.hidden = expanded;
});
});
qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => {
button.addEventListener('click', () => {
const filename = button.dataset.pullReviewFile;
pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename);
renderPullReview(detail, filename);
});
});
if (focusFilename) {
Array.from(qs('#pull-files').querySelectorAll('.pull-review-file'))
.find(button => button.dataset.pullReviewFile === focusFilename)?.focus();
}
}
function renderPullCheckStatus(status) {
qs('#pull-ci-state').textContent = 'CI ' + (status.ci_state || 'unknown');
renderCheckSection('pull', status);
if (status.head_sha !== selectedPullDetail?.head_sha) {
qs('#pull-review-status').textContent = 'New commits detected. Reload review data before merging.';
qs('#pull-merge-state').textContent = 'Review data is stale';
qs('#merge-pull').disabled = true;
return;
}
selectedPullDetail = { ...selectedPullDetail, ...status };
const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
}
async function loadPullReview({ refresh = false } = {}) {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const readDetail = selectedPullDetail;
qs('#pull-review-status').textContent = 'Loading changed files and merge readiness…';
qs('#pull-review-retry').hidden = true;
qs('#merge-pull').disabled = true;
try {
const review = await pullController.loadReview(item, readDetail.head_sha, { refresh });
if (selectedPull !== item) return;
selectedPullDetail = { ...readDetail, ...review };
qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown');
renderPullReview(selectedPullDetail);
qs('#pull-review-status').textContent = review.head_sha === readDetail.head_sha ?
'Review data ready for the current head.' : 'New commits detected. Review progress restarted for the latest head.';
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-review-status').textContent = error.message + ' Reading and replies remain available; retry here.';
qs('#pull-review-retry').hidden = false;
qs('#pull-merge-state').textContent = 'Review data unavailable';
qs('#merge-pull').disabled = true;
}
}
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
selectedPull = item;
pullMentions.dismiss();
pullTrigger = trigger;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-review').open = false;
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
qs('#pull-review-retry').hidden = true;
qs('#pull-review-progress').textContent = 'Review progress unavailable.';
qs('#next-unreviewed-pull-file').disabled = true;
qs('#pull-comments').textContent = '';
qs('#load-older-pull-comments').hidden = true;
qs('#pull-conversation-status').textContent = 'Loading newest messages…';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-checks-summary').textContent = 'Not loaded';
qs('#pull-check-list').textContent = '';
qs('#pull-checks').open = false;
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
createPullSheet.resetOwnershipControls(document, item, candidate => workSession.checkpointed(candidate));
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
setCommentNextVisibility('pull');
qs('#close-pull-sheet').focus();
try {
const detail = offlineDetail || await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
pullConversation = pullController.conversation(item, detail.conversation);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
if (offlineDetail) {
setOfflineDetailControls('pull');
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-pull-load').hidden = false;
qs('#retry-pull-load').focus();
}
}
function closePullSheet(navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
pullAttachmentController.clear();
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
function selectedIssueLabelIds() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => Number(input.value)).filter(Number.isInteger);
}
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
const selection = findWorkController.selection();
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
const selected = findWorkController.isSelected(item);
const detailId = 'find-work-detail-' + index;
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
'><div class="find-work-description markdown-content">' + renderMarkdown(item.body || 'No description provided.') + '</div>' +
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
'</div>';
return '<article class="find-work-card' + (selected ? ' selected' : '') + '">' +
(selection.active ? '<label class="find-work-select"><input type="checkbox" data-find-work-select="' + index +
'"' + (selected ? ' checked' : '') + ' /> Select ' + escapeHtml(item.title || 'Untitled issue') + '</label>' : '') +
'<div class="small">' + escapeHtml(item.repository) + '#' +
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
detail + '<div class="find-work-claim-actions"' + (selection.active ? ' hidden' : '') + '><button type="button" data-claim-index="' + index +
'">Assign</button><button type="button" data-claim-queue-index="' + index +
'">Queue Today</button><button type="button" data-claim-start-index="' + index +
'">Start now</button></div></article>';
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
list.querySelectorAll('[data-find-work-select]').forEach(input => {
input.addEventListener('change', () => {
const item = findWorkController.items()[Number(input.dataset.findWorkSelect)];
if (item) findWorkController.toggleSelection(item);
});
});
list.querySelectorAll('[data-preview-index]').forEach(button => {
button.addEventListener('click', () => {
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
if (!item) return;
const expanded = findWorkController.togglePreview(item);
const detail = document.getElementById(button.getAttribute('aria-controls'));
button.setAttribute('aria-expanded', String(expanded));
button.textContent = expanded ? 'Hide details' : 'View details';
if (detail) detail.hidden = !expanded;
});
});
list.querySelectorAll('[data-claim-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
if (!item) return;
button.disabled = true;
try {
const confirmed = await findWorkController.claim(item);
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
openRoutedWork(claimed, qs('#find-work'));
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
button.disabled = false;
button.focus();
}
});
});
list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
if (!item) return;
const claimButtons = button.closest('.find-work-card')
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
const outcome = await assignAndStart.run(item, { destination:'queue' });
if (outcome === 'queued') {
(qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
}
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
claimButtons.forEach(action => { action.disabled = false; });
button.focus();
}
});
});
list.querySelectorAll('[data-claim-start-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimStartIndex)];
if (!item) return;
const claimButtons = button.closest('.find-work-card')
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
await assignAndStart.run(item);
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
claimButtons.forEach(action => { action.disabled = false; });
button.focus();
}
});
});
}
async function openFindWorkSheet(navigate = true) {
if (navigate) {
taskOverlayHistory.open('find');
return;
}
findingWork = true;
qs('#find-work-sheet').classList.add('open');
const retainedItems = findWorkController.items();
if (retainedItems.length) renderAvailableIssues(retainedItems);
else qs('#find-work-list').textContent = '';
qs('#find-work-status').textContent = retainedItems.length ?
'Refreshing available issues…' : 'Loading available issues…';
qs('#load-more-available').hidden = true;
qs('#close-find-work').focus();
try {
const result = await findWorkController.load();
if (!result?.stale) {
qs('#find-work-status').textContent = findWorkController.items().length ?
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
'No unassigned issues are available.';
}
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Close and retry.';
}
}
function closeFindWorkSheet(navigate = true) {
if (navigate && taskOverlayHistory.current() === 'find') {
taskOverlayHistory.close();
return;
}
findingWork = false;
qs('#find-work-sheet').classList.remove('open');
qs('#find-work').focus();
}
function saveIssueCaptureDraft() {
if (!issueCapture) return;
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
});
}
function currentIssueCaptureDraft() {
return {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
};
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
let issueRepositorySearchTimer = null;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
const selected = select.value;
const known = new Set(issueCaptureRepositories);
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository || known.has(repository)) return;
known.add(repository);
issueCaptureRepositories.push(repository);
const option = document.createElement('option');
option.value = repository;
option.textContent = repository;
select.appendChild(option);
});
if (selected) select.value = selected;
}
function updateIssueCreateActions() {
const hasRepository = Boolean(qs('#create-issue-repository').value);
qs('#submit-new-issue').disabled = !hasRepository;
qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available();
}
function renderIssueRepositoryResults(items) {
const results = qs('#create-issue-repository-results');
results.replaceChildren();
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'create-issue-repository-result';
button.setAttribute('role', 'option');
button.textContent = repository;
button.addEventListener('click', () => selectIssueCaptureRepository(repository));
results.appendChild(button);
});
results.hidden = !results.childElementCount;
}
function selectIssueCaptureRepository(repository) {
appendIssueRepositories([{full_name: repository}]);
qs('#create-issue-repository').value = repository;
qs('#create-issue-repository-search').value = repository;
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
loadIssueLabels(repository);
loadIssueMilestones(repository);
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
qs('#create-issue-title').focus();
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
const panel = qs('#create-issue-duplicates');
const list = qs('#create-issue-duplicate-list');
const status = qs('#create-issue-duplicate-status');
const anyway = qs('#create-issue-anyway');
list.replaceChildren();
anyway.hidden = true;
if (state.status === 'idle') {
panel.hidden = true;
return;
}
panel.hidden = false;
if (state.status === 'failed') {
status.textContent = 'Could not check for existing issues. You can still create this issue.';
return;
}
if (!state.candidates.length) {
status.textContent = 'No similar open issues found in this repository.';
return;
}
status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
state.candidates.forEach(item => {
const card = document.createElement('article');
card.className = 'create-issue-duplicate-card';
const key = document.createElement('span');
key.className = 'small';
key.textContent = item.repository + ' #' + item.number;
const link = document.createElement('a');
link.textContent = item.title || 'Review existing issue';
link.href = safeSearchUrl(item.url) || '#';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.addEventListener('click', saveIssueCaptureDraft);
card.append(key, link);
list.appendChild(card);
});
}
function scheduleIssueDuplicateCheck() {
clearTimeout(duplicateCheckTimer);
const draft = currentIssueCaptureDraft();
duplicateCheckTimer = setTimeout(async () => {
renderIssueDuplicates(await issueCapture.findDuplicates(draft));
}, 350);
}
async function loadIssueLabels(repository, selectedIds = []) {
const list = qs('#create-issue-label-list');
const status = qs('#create-issue-label-status');
list.innerHTML = '';
if (!repository) {
status.textContent = 'Choose a repository to load labels.';
return;
}
status.textContent = 'Loading labels…';
try {
const labels = await issueCapture.loadLabels(repository);
const selected = new Set(selectedIds.map(Number));
list.innerHTML = labels.map(label =>
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></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 = '<option value="">No milestone</option>';
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 =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
if (selectedId) select.value = String(selectedId);
status.textContent = milestones.length ?
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
} catch (_error) {
if (qs('#create-issue-repository').value !== repository) return;
status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
}
}
function clearSharedLaunchUrl() {
const cleanUrl = location.pathname + location.hash;
history.replaceState(history.state || {}, '', cleanUrl);
}
function openStagedSharedContent() {
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
openCreateIssueSheet();
if (sharedLaunchState.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
sharedLaunchState = {status: 'shown'};
return;
}
clearSharedLaunchUrl();
sharedLaunchState = null;
}
async function openCreateIssueSheet(navigate = true) {
if (!issueCapture && !await ensureIssueCapture()) return;
if (navigate) {
taskOverlayHistory.open('new');
return;
}
const captureDraft = issueCapture.loadDraft();
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
issueCaptureRepositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = '<option value="">Choose repository</option>' + issueCaptureRepositories.map(repository =>
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
).join('');
if (nextIssueRepositoryPage === 2) {
moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true;
}
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
else qs('#create-issue-repository').value = '';
qs('#create-issue-repository-search').value = captureDraft.repository || '';
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
setIssueFilingMode(Boolean(captureDraft.repository));
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
updateIssueCreateActions();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
qs('#create-issue-title').focus();
}
function setIssueFilingMode(enabled) {
qs('#create-issue-filing').hidden = !enabled;
qs('.create-issue-capture-actions').hidden = enabled;
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
}
let suppressCreateDraftOnHistoryClose = false;
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {
suppressCreateDraftOnHistoryClose = !preserveDraft;
taskOverlayHistory.close();
return;
}
qs('#create-issue-sheet').classList.remove('open');
mobileComposerViewport.close(qs('.create-issue-panel'));
clearTimeout(duplicateCheckTimer);
qs('#create-issue-duplicates').hidden = true;
creatingIssue = false;
if (followUpSourceUpdate) {
const source = followUpSourceUpdate;
followUpSourceUpdate = null;
notificationReader.open(source.item, source.detail);
return;
}
qs('#new-issue').focus();
}
function applyOutboxResult(result, openCreated = false, startCreated = false) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
(result.confirmed || []).forEach(confirmed => {
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
});
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
const startedCompletions = new Set();
(result.completions || []).forEach(completion => {
if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return;
const created = lastMyWork.find(item => item.kind === 'issue' &&
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
if (!created) return;
const outcome = createAndStart.complete(created);
if (outcome === 'started' || outcome === 'exists') {
issueOutbox.completeIntent(completion.id, activeFlushLogin);
startedCompletions.add(created.repository + '#' + created.number);
}
});
if (result.confirmed?.length) {
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
const confirmed = result.confirmed[result.confirmed.length - 1];
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
if (startCreated && !(result.completions || []).length && created) {
const outcome = createAndStart.complete(created);
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
} else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue'));
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
} else {
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
}
}
async function flushIssueOutbox() {
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
}
function applyAuthoredOutboxResult(result) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
refreshMyWorkView();
const attention = (result.remaining || []).some(item => item.status === 'attention');
qs('#my-work-action-status').textContent = result.confirmed?.length ?
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
'Message queued for sync when the connection returns.';
}
async function flushAuthoredOutbox() {
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
}
async function flushNotificationReadOutbox() {
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
const result = await notificationReadOutbox.flush(activeFlushLogin);
if (result.confirmed?.length) {
qs('#my-work-action-status').textContent = result.confirmed.length +
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
}
}
function canQueueMessage(error) {
const status = Number(error?.status || 0);
return !status || status >= 500;
}
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
};
}
function sameInlineAnchor(comment, anchor) {
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
comment.old_position === anchor.old_position;
}
function closeInlineComposer() {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-inline-composer').hidden = true;
qs('#review-inline-body').value = '';
activeInlineTarget = null;
}
function openInlineComposer(target) {
if (!draft) return;
activeInlineTarget = target;
const anchor = inlineAnchor(target);
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
qs('#review-inline-body').value = existing?.body || '';
qs('#delete-inline-comment').disabled = !existing;
qs('#review-inline-composer').hidden = false;
qs('#review-inline-body').focus();
}
async function openReviewSheet(item, trigger, cachedDetail = null) {
if (!item || !await ensurePullWorkflow(trigger)) return;
selectedReview = item;
reviewTrigger = trigger;
offlineReview = Boolean(cachedDetail);
qs('#review-sheet').classList.add('open');
qs('#review-sheet-key').textContent = item.key;
qs('#review-sheet-title').textContent = item.title;
qs('#review-sheet-status').textContent = 'Loading review details…';
qs('#retry-review-load').hidden = true;
qs('#review-sheet-body').textContent = '';
qs('#review-files').textContent = '';
applyReviewWrap();
qs('#review-progress').textContent = '0 of 0 files reviewed';
qs('#next-unreviewed-review').disabled = true;
qs('#review-history').textContent = '';
qs('#open-review-gitea').href = item.url;
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
qs('#review-copy-fallback').hidden = true;
qs('#review-copy-fallback').value = '';
qs('#review-handoff-link').hidden = true;
qs('#review-handoff-link').href = item.url;
qs('#review-handoff-status').textContent = '';
qs('#review-submit-status').textContent = '';
qs('#continue-review-to-merge').hidden = true;
qs('#review-ci-state').textContent = 'CI unknown';
qs('#review-checks-summary').textContent = cachedDetail ? 'Last known · loading' : 'Loading';
qs('#review-check-list').textContent = '';
qs('#review-checks').open = false;
qs('#refresh-review-checks').disabled = offlineReview;
qs('#submit-review').disabled = true;
qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' :
(offlineReview ? 'Queue review for reconnect' : 'Submit review');
closeInlineComposer();
draft = null;
reviewFiles = [];
selectedReviewHead = '';
qs('#close-review-sheet').focus();
try {
const detail = cachedDetail || await reviewController.load(selectedReview);
if (selectedReview !== item) return;
qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail, offlineReview);
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createReviewController.renderDiffFile(file, index, escapeHtml)
).join('') : '<div>No changed files reported.</div>';
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 =>
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small markdown-content">' + renderMarkdown(review.body) + '</div>' : '') + '</div>'
).join('') : '<div>No prior reviews.</div>';
qs('#review-sheet-status').textContent = offlineReview ?
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = false;
} catch (error) {
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
qs('#retry-review-load').hidden = false;
qs('#retry-review-load').focus();
}
}
function closeReviewSheet(navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
closeInlineComposer();
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
function closeUpdateSheet(restoreTrigger = true, navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
updateReplyAttachmentController.clear();
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
selectedUpdateDetail = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
else qs('[data-work-filter="update"]')?.focus();
}
function markMyWorkStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastMyWork.length ?
'Update failed · showing last known work' : 'Work inbox unavailable.';
}
function markNotificationsStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastNotifications.length ?
'Unread updates unavailable · showing last known updates' :
'Unread updates unavailable · assigned work is fresh';
}
function paintDeltas(deltas) {
const el = qs('#ai');
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
}
function paintEventStream(events) {
const el = qs('#gitea-events');
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
escapeHtml(event.type || 'activity') +
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
).join('') : '<div class="muted">No recent Gitea activity.</div>';
}
function setEventStreamStatus(message) {
qs('#gitea-events-status').textContent = message;
}
async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
setOfflineWorkMode(false);
offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context;
const eventsFreshness = snapshot.freshness?.sections?.events;
const notificationFreshness = snapshot.freshness?.sections?.notifications;
const contextChanged = changedSections.includes('context');
const notificationsChanged = changedSections.includes('notifications');
const eventsChanged = changedSections.includes('events');
const workChanged = contextChanged || notificationsChanged;
const hasNotifications = Array.isArray(snapshot.notifications);
const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
if (notificationsChanged && hasNotifications) {
if (notificationPagination.page > 1) {
const byId = new Map(lastNotifications.map(item => [item.id, item]));
snapshot.notifications.forEach(item => byId.set(item.id, item));
lastNotifications = Array.from(byId.values());
} else {
lastNotifications = snapshot.notifications;
}
if (snapshot.notification_pagination) {
const page = notificationPagination.page > 1 ? notificationPagination.page :
snapshot.notification_pagination.page;
notificationPager.reset({
page,
total: snapshot.notification_pagination.total,
has_more: page * 50 < snapshot.notification_pagination.total,
});
}
}
if (snapshot.context && workChanged) {
setOfflineWorkMode(false);
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
if (planningOwnerLogin) {
todaySync.migrate(todayWork.read());
todaySync.flush();
laterSync.migrate(laterWork.read());
laterSync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
updateDeliveryReceiptControls();
interruptionPrompt.restore();
}
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale();
if (!contextFreshness?.stale && notificationsFresh) {
const admitted = await offlineWorkStore.save({
...snapshot.context,
notifications: snapshot.notifications,
notification_pagination: snapshot.notification_pagination,
});
await updateOfflineWorkControls(admitted === false ?
'Offline saving unavailable · retry after reconnect.' : undefined);
warmTodayOffline();
}
flushIssueOutbox();
flushAuthoredOutbox();
flushNotificationReadOutbox();
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
setEventStreamStatus('Refreshing activity · showing last activity');
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) {
const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0;
setEventStreamStatus('Activity refresh failed · showing last activity' +
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
} else if (Array.isArray(snapshot.events)) {
setEventStreamStatus('Updated ' + fmt(new Date()));
} else {
setEventStreamStatus('Update failed · showing last activity');
}
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
requestAnimationFrame(draw);
};
draw();
})();
/* Whiteboard */
function initWhiteboard() {
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
let drawing = false;
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
resize(); addEventListener('resize', resize);
const p = (e) => ({ 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 = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
}
/* Commands */
const commands = [
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh now', run: load },
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
];
let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = [];
let commandSelection = -1;
async function searchGlobalWork(query, signal) {
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
headers: { Accept:'application/json' },
signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
return {
items: Array.isArray(payload.items) ? payload.items : [],
partial: payload.partial === true,
};
}
const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork,
onState: state => {
commandSearchState = state;
renderCommands(state.query);
},
});
function safeSearchUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return '';
}
}
let searchPreviewDetail = null;
function searchPreviewPath(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
}
function renderSearchPreview(state) {
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const startButton = qs('#start-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
if (state.status === 'closed') {
sheet.classList.remove('open');
return;
}
sheet.classList.add('open');
claimButton.hidden = true;
claimButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
if (state.status === 'loading') {
searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
qs('#search-preview-meta').textContent = '';
qs('#search-preview-body').textContent = '';
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
status.textContent = 'Loading preview…';
return;
}
if (state.status === 'error') {
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
return;
}
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
qs('#search-preview-meta').textContent =
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
(detail.author ? ' · by ' + detail.author : '') +
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable ||
(detail.state === 'open' && (detail.claimable || detail.assigned_to_me))));
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
status.textContent = state.status === 'reopening' ? 'Reopening…' :
(state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' :
(detail.assigned_to_me ? 'This issue is already in My Work.' :
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
}
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
mutate: (detail, action) => fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
onState: renderSearchPreview,
});
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,
claim,
start: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(item);
},
recover: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(item, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
schedule: callback => requestAnimationFrame(callback),
});
function closeSearchPreview(navigate = true) {
if (navigate && taskOverlayHistory.current() === 'search-preview') {
taskOverlayHistory.close();
return;
}
searchPreview.close();
qs('#cmd-palette').classList.add('open');
qs('#cmd-input').setAttribute('aria-expanded', 'true');
renderCommands(qs('#cmd-input').value);
qs('#cmd-input').focus();
}
async function openPreviewIssueInMyWork(detail) {
await load();
const item = lastMyWork.find(candidate =>
candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number
);
if (!item) return false;
taskOverlayHistory.leave();
openRoutedWork(item, qs('#find-work'));
return true;
}
function runCommandItem(item) {
if (item.command) {
taskOverlayHistory.leave();
item.command.run();
qs('#cmd-input').value = '';
} else {
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
taskOverlayHistory.open('search-preview');
}
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
}
function renderCommands(filter) {
const el = qs('#cmd-results');
const local = filterCommands(commands, filter).map(command => ({ command }));
const remote = commandSearchState.query === String(filter || '').trim()
? commandSearchState.items.map(result => ({ result })) : [];
commandItems = local.concat(remote);
if (commandSelection >= commandItems.length) commandSelection = -1;
let html = local.length ? '<div class="cmd-group">Commands</div>' : '';
html += local.map((item, idx) => '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(item.command.name) + '</span><span class="cmd-meta">Command</span></div>').join('');
if (remote.length) html += '<div class="cmd-group">Issues and pull requests</div>';
html += remote.map((item, remoteIdx) => {
const idx = local.length + remoteIdx;
const result = item.result;
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
}).join('');
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
else if (commandSearchState.status === 'error') html += '<div class="cmd-status">Search unavailable. Keep typing or retry.</div>';
else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>';
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
el.innerHTML = html;
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
});
}
function openCommandPalette(navigate = true) {
if (navigate) {
taskOverlayHistory.open('search');
return;
}
qs('#cmd-palette').classList.add('open');
mobileSearchViewport.open();
mobileSearchViewport.restoreScroll();
qs('#cmd-input').setAttribute('aria-expanded', 'true');
qs('#cmd-input').focus();
commandSelection = -1;
renderCommands(qs('#cmd-input').value);
}
const taskOverlayHistory = createTaskOverlayHistory({
history: window.history,
eventTarget: window,
onChange(kind, previous) {
if (previous === 'new' && kind !== 'new') {
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
suppressCreateDraftOnHistoryClose = false;
closeCreateIssueSheet(false);
}
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
if (previous === 'search-preview' && kind !== 'search-preview') {
if (kind === 'search') closeSearchPreview(false);
else {
searchPreview.close();
mobileSearchViewport.close();
}
searchPreviewReturnKind = null;
}
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
mobileSearchViewport.close();
qs('#open-palette').focus();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
else if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
else planTodayPreview.close();
addPlanPreviewOnReturn = false;
addPlanPreviewOverride = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
if (previous === 'today-readiness' && kind !== 'today-readiness') {
if (kind === 'search-preview') suspendTodayReadiness();
else closeTodayReadiness(false);
}
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
},
});
taskOverlayHistory.start();
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1;
renderCommands(e.target.value);
commandSearch.setQuery(e.target.value);
});
qs('#cmd-input').addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
renderCommands(event.currentTarget.value);
const selected = qs('#cmd-results .selected');
if (selected) selected.scrollIntoView({ block:'nearest' });
} else if (event.key === 'Enter' && commandSelection >= 0) {
event.preventDefault();
runCommandItem(commandItems[commandSelection]);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
e.preventDefault();
taskOverlayHistory.close();
return;
}
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
e.preventDefault();
closeSearchPreview();
return;
}
if (e.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
return;
}
if (e.key === 'Escape' && creatingIssue) {
e.preventDefault();
saveIssueCaptureDraft();
closeCreateIssueSheet();
return;
}
if (e.key === 'Escape' && selectedIssue) {
e.preventDefault();
closeIssueSheet();
return;
}
if (e.key === 'Escape' && selectedPull) {
e.preventDefault();
closePullSheet();
return;
}
if (e.key === 'Escape' && selectedReview) {
e.preventDefault();
closeReviewSheet();
return;
}
if (e.key === 'Escape' && selectedUpdate) {
e.preventDefault();
closeUpdateSheet();
return;
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
if (qs('#cmd-palette').classList.contains('open')) {
taskOverlayHistory.close();
} else openCommandPalette();
}
});
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
try {
const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard);
status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.';
} catch (error) {
status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.';
}
});
});
qs('#claim-search-result').addEventListener('click', async () => {
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
try {
const claimed = searchPreviewDetail;
if (claimed.claimable) await searchPreview.claim(claimed);
const opened = await openPreviewIssueInMyWork(claimed);
if (!opened) {
qs('#search-preview-status').textContent =
'Assignment confirmed. Refresh My Work to open the issue.';
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue') return;
try {
if (detail.reopenable) await searchReopenAndStart.run(detail);
else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
qs('#fill-find-work-today').addEventListener('click', () => {
const remainingSlots = todayWork.limit - todayWork.read().length;
const outcome = findWorkController.fillSelection(remainingSlots);
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
if (outcome.selected && todayWork.planning().capacity_minutes !== null)
openFindWorkEstimateReview(findWorkController.selectedItems());
});
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
qs('#find-work-search').addEventListener('input', event => {
const value = event.currentTarget.value;
qs('#clear-find-work-search').hidden = !value;
clearTimeout(findWorkSearchTimer);
findWorkSearchTimer = setTimeout(async () => {
qs('#find-work-status').textContent = value.trim() ?
'Searching available issues…' : 'Loading available issues…';
try {
await findWorkController.search(value);
updateFindWorkMatchStatus();
qs('#find-work-status').textContent = availablePagination.total ?
'Available work loaded.' : 'No matching unassigned issues.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Retry search.';
}
}, 250);
});
qs('#clear-find-work-search').addEventListener('click', () => {
const input = qs('#find-work-search');
input.value = '';
input.dispatchEvent(new Event('input', { bubbles:true }));
input.focus();
});
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
qs('#claim-selected-work').addEventListener('click', async event => {
if (todayWork.planning().capacity_minutes !== null) {
openFindWorkEstimateReview(findWorkController.selectedItems());
return;
}
event.currentTarget.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems());
if (outcome.status === 'complete') {
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
const unavailable = outcome.failed.length - assignedOnly;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
if (outcome.failed.length) {
renderAvailableIssues(findWorkController.items());
event.currentTarget.disabled = false;
} else {
findWorkController.cancelSelection();
}
refreshMyWorkView();
} else {
event.currentTarget.disabled = false;
}
});
qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview);
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
event.currentTarget.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
event.currentTarget.disabled = false;
if (outcome.status === 'estimates-required') {
const first = outcome.invalid[0];
document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus();
return;
}
if (outcome.status !== 'complete') return;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.');
closeFindWorkEstimateReview();
if (!outcome.failed.length) findWorkController.cancelSelection();
refreshMyWorkView();
});
qs('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';
try {
await findWorkController.loadMore();
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
availablePagination.total + ' available issues loaded.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
} finally {
event.currentTarget.disabled = false;
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#create-update-follow-up').addEventListener('click', async () => {
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
updateFollowUp.stageSource(source.item);
followUpSourceUpdate = source;
closeUpdateSheet(false, false);
await openCreateIssueSheet(false);
if (state.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
} else {
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
}
qs('#create-follow-up-next').hidden = false;
});
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
if (!qs('#create-issue-title').value.trim()) {
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
qs('#create-issue-title').focus();
return;
}
saveIssueCaptureDraft();
setIssueFilingMode(true);
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-repository-search').focus();
});
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
requestAnimationFrame(() => {
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when youre ready to file it.';
} catch (error) {
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog();
qs('#use-shared-content').addEventListener('click', () => {
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
else issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
openCreateIssueSheet();
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
});
qs('#resume-issue-draft').addEventListener('click', () => {
issueCapture.discardSharedContent();
issueCapture.discardFollowUp();
updateFollowUp.discardSource();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
qs('#create-issue-status').textContent = 'Existing draft restored.';
qs('#create-issue-title').focus();
});
qs('#cancel-new-issue').addEventListener('click', () => {
const discardEditedDraft = Boolean(editingOutboxId);
if (editingOutboxId) {
issueCapture.clearDraft();
editingOutboxId = null;
} else saveIssueCaptureDraft();
closeCreateIssueSheet(true, !discardEditedDraft);
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
qs(selector).addEventListener('input', () => {
saveIssueCaptureDraft();
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
})
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
loadIssueMilestones(event.target.value);
qs('#create-issue-repository-search').value = event.target.value;
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
});
qs('#create-issue-repository-search').addEventListener('input', event => {
clearTimeout(issueRepositorySearchTimer);
const query = event.target.value.trim();
const results = qs('#create-issue-repository-results');
const status = qs('#create-issue-repository-status');
if (query.length < 2) {
issueCapture.searchRepositories(query);
results.hidden = true;
status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.';
return;
}
status.textContent = 'Searching accessible repositories…';
issueRepositorySearchTimer = setTimeout(async () => {
const state = await issueCapture.searchRepositories(query);
if (state.status === 'stale') return;
if (state.status === 'failed') {
results.hidden = true;
status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.';
return;
}
renderIssueRepositoryResults(state.items);
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
}, 250);
});
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
saveIssueCaptureDraft();
button.disabled = true;
status.textContent = 'Loading more repositories…';
try {
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
appendIssueRepositories(payload.items);
nextIssueRepositoryPage = payload.page + 1;
moreIssueRepositoriesAvailable = payload.has_more;
button.hidden = !moreIssueRepositoriesAvailable;
status.textContent = payload.items.length ?
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
} catch (_error) {
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-anyway').addEventListener('click', () => {
const captureDraft = currentIssueCaptureDraft();
issueCapture.acknowledgeDuplicates(captureDraft);
qs('#create-issue-anyway').hidden = true;
qs('#create-issue-form').requestSubmit();
});
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
qs('#create-issue-anyway').hidden = false;
qs('#create-issue-anyway').focus();
return;
}
if (createAndStartRequested && !createAndStart.available()) {
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
qs('#create-and-start-issue').focus();
return;
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
const followUpButton = qs('#create-follow-up-next');
button.disabled = true;
startButton.disabled = true;
followUpButton.disabled = true;
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = followUpNextRequested ? (await updateFollowUp.complete({
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
issueOutbox.enqueueDurably(durableDraft),
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft));
const queued = admission.item;
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
refreshMyWorkView();
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
return;
}
editingOutboxId = null;
followUpSourceUpdate = null;
if (!followUpNextRequested) updateFollowUp.discardSource();
issueCapture.clearDraft();
createIssueAttachmentController.clear();
suppressCreateDraftOnHistoryClose = true;
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Queued for sync.';
if (navigator.onLine) applyOutboxResult(
await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested
);
createAndStartRequested = false;
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
followUpButton.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 =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
paintMyWork(lastContextSnapshot);
qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.';
} catch (error) {
qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.';
} finally {
button.disabled = false;
}
});
async function saveSelectedIssueDueDate(dueDate) {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const saveButton = qs('#save-issue-due-date');
const clearButton = qs('#clear-issue-due-date');
saveButton.disabled = true;
clearButton.disabled = true;
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
try {
const confirmed = await issueController.updateDueDate(editing, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
);
selectedIssue = { ...editing, due_date: confirmed.due_date };
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
paintMyWork(lastContextSnapshot);
qs('#issue-due-status').textContent = confirmed.due_date ?
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
} catch (error) {
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
qs('#issue-due-date').focus();
} finally {
saveButton.disabled = false;
clearButton.disabled = !selectedIssueDetail?.due_date;
}
}
qs('#save-issue-due-date').addEventListener('click', () => {
const value = qs('#issue-due-date').value;
if (!value) {
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
qs('#issue-due-date').focus();
return;
}
saveSelectedIssueDueDate(value + 'T23:59:59Z');
});
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
qs('#save-issue-milestone').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const button = qs('#save-issue-milestone');
const raw = qs('#issue-milestone').value;
const milestoneId = raw ? Number(raw) : null;
button.disabled = true;
qs('#issue-milestone').disabled = true;
qs('#issue-milestone-status').textContent = milestoneId ? 'Saving milestone…' : 'Clearing milestone…';
try {
const confirmed = await issueController.updateMilestone(editing, milestoneId);
lastContextSnapshot = buildMyWork.replaceIssueMilestone(
lastContextSnapshot, editing.repository, editing.number, confirmed.milestone
);
selectedIssue = { ...editing, milestone: confirmed.milestone };
selectedIssueDetail = { ...selectedIssueDetail, milestone: confirmed.milestone };
paintMyWork(lastContextSnapshot);
qs('#issue-milestone-status').textContent = confirmed.milestone ?
'Planned for ' + confirmed.milestone.title + '. Release lane updated.' : 'Milestone cleared.';
} catch (error) {
qs('#issue-milestone-status').textContent = error.message + ' Your selection is safe; retry.';
qs('#issue-milestone').focus();
} finally {
button.disabled = false;
qs('#issue-milestone').disabled = false;
}
});
async function queueIssueScreenshotComment(item, body, operationId, advance = false) {
const message = {
kind: 'issue-comment', repository: item.repository, number: item.number, body,
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
attachment: await issueAttachmentController.serialize(),
};
if (advance) return await issueCommentNext.admit(item, message);
const admission = await authoredOutbox.enqueueDurably(message);
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
return admission;
}
async function queuePullScreenshotComment(item, body, operationId, advance = false, deliver = false) {
const message = {
kind: 'pull-comment', repository: item.repository, number: item.number, body,
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
attachment: await pullAttachmentController.serialize(),
};
if (advance) return await pullCommentNext.admit(item, message);
const admission = await authoredOutbox.enqueueDurably(message);
if (deliver) {
const delivery = await authoredOutbox.retry(admission.item.id, activeFlushLogin);
return { ...admission, delivered: delivery.confirmed?.[0] || null };
}
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
return admission;
}
async function submitCommentAndNext(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
if (!item || !workSession.checkpointed(item)) return;
const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim();
const attachmentController = kind === 'issue' ? issueAttachmentController : pullAttachmentController;
const queueScreenshot = kind === 'issue' ? queueIssueScreenshotComment : queuePullScreenshotComment;
if (!body && !attachmentController.state()) {
status.textContent = 'Write a comment before posting.';
textarea.focus();
return;
}
const postButton = qs('#send-' + kind + '-comment');
const nextButton = qs('#send-' + kind + '-comment-next');
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
item.repository + '#' + item.number + ':operation');
postButton.disabled = true;
nextButton.disabled = true;
attachmentController.setBusy(true);
status.textContent = attachmentController.state() ?
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
try {
let result;
if (attachmentController.state() && (kind === 'pull' || navigator.onLine === false)) {
result = await queueScreenshot(item, body, operationId(), true);
} else {
let preparedBody;
try {
preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) :
await pullAttachmentController.prepareComment(item, body);
} catch (error) {
if (!attachmentController.state() || !canQueueMessage(error)) throw error;
result = await queueScreenshot(item, body, operationId(), true);
}
if (!result) result = await controller.submit(item, preparedBody, operationId);
}
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
attachmentController.clear();
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
else status.textContent = 'Saved for next launch; background delivery unavailable.';
} catch (error) {
status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.';
textarea.focus();
} finally {
attachmentController.setBusy(false);
postButton.disabled = false;
nextButton.disabled = false;
}
}
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
if (!body && !issueAttachmentController.state()) {
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
qs('#issue-comment').focus();
return;
}
const button = qs('#send-issue-comment');
button.disabled = true;
qs('#issue-comment-status').textContent = issueAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
let preparedBody;
try {
if (issueAttachmentController.state() && navigator.onLine === false) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
button.disabled = false;
return;
}
preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body);
} catch (error) {
if (issueAttachmentController.state() && canQueueMessage(error)) {
try {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
button.disabled = false;
return;
} catch (admissionError) {
error = admissionError;
}
}
qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#issue-comment').focus();
button.disabled = false;
return;
}
try {
const comment = await issueController.comment(selectedIssue, preparedBody);
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
qs('#issue-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body:preparedBody, operationId });
refreshMyWorkView();
if (admission.background) {
qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
qs('#issue-comment').focus();
}
} else {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
}
} finally {
button.disabled = false;
}
});
qs('#release-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
const releasing = selectedIssue;
const continuingSession = workSession.checkpointed(releasing);
const button = qs('#release-issue');
button.disabled = true;
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
try {
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, releasing.repository, releasing.number
);
closeIssueSheet();
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(releasing);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = releasing.key + ' released. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = releasing.key + ' released. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = releasing.key + ' released, but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#load-issue-handoff').addEventListener('click', async () => {
if (!selectedIssue) return;
const button = qs('#load-issue-handoff');
const select = qs('#issue-handoff-recipient');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
try {
const candidates = await issueController.loadHandoffCandidates(selectedIssue);
select.textContent = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
candidates.forEach(candidate => {
const option = document.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
select.disabled = !candidates.length;
qs('#confirm-issue-handoff').disabled = true;
qs('#issue-handoff-status').textContent = candidates.length ?
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
if (candidates.length) select.focus();
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
button.disabled = false;
button.focus();
}
});
qs('#issue-handoff-recipient').addEventListener('change', event => {
qs('#confirm-issue-handoff').disabled = !event.target.value;
});
qs('#confirm-issue-handoff').addEventListener('click', async () => {
const recipient = qs('#issue-handoff-recipient').value;
if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
const handingOff = selectedIssue;
const continuingSession = workSession.checkpointed(handingOff);
const button = qs('#confirm-issue-handoff');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
try {
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, handingOff.repository, handingOff.number
);
closeIssueSheet();
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(handingOff);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + ', but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
}
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
const closing = selectedIssue;
const button = qs('#close-issue');
button.disabled = true;
if (selectedIssueOffline) {
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
try {
const outcome = await closeOfflineIssue(closing);
closeIssueSheet();
refreshMyWorkView({ reconcileSession:false });
if (!outcome.advanced) {
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
} else if (outcome.admission.background) {
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
} else {
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
button.disabled = false;
button.focus();
}
return;
}
qs('#issue-sheet-status').textContent = 'Closing issue…';
try {
await issueController.close(selectedIssue);
closeIssueSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = closing.key + ' closed.';
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#pull-review').addEventListener('toggle', event => {
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#refresh-pull-checks').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const button = qs('#refresh-pull-checks');
button.disabled = true;
qs('#pull-checks-summary').textContent = 'Refreshing…';
try {
const status = await pullController.loadChecks(item);
if (selectedPull !== item) return;
renderPullCheckStatus(status);
} catch (_error) {
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-pull-file').addEventListener('click', () => {
createPullSheet.focusNextUnreviewed(document, selectedPullDetail, pullReviewState, pullController);
});
qs('#load-older-pull-comments').addEventListener('click', async () => {
if (!pullConversation) return;
const button = qs('#load-older-pull-comments');
const panel = qs('#pull-sheet .pull-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#pull-conversation-status').textContent = 'Loading older messages…';
try {
renderPullConversation(await pullConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
qs('#send-pull-comment').addEventListener('click', async () => {
if (!selectedPull) return;
const item = selectedPull;
const body = qs('#pull-comment').value.trim();
if (!body && !pullAttachmentController.state()) {
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
qs('#pull-comment').focus();
return;
}
const button = qs('#send-pull-comment');
button.disabled = true;
pullAttachmentController.setBusy(true);
qs('#pull-comment-status').textContent = pullAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' +
item.repository + '#' + item.number + ':operation');
let preparedBody;
try {
if (pullAttachmentController.state()) {
const admission = await queuePullScreenshotComment(item, body, operationId, false, true);
refreshMyWorkView();
pullController.saveDraft(item, '');
if (selectedPull === item) {
if (admission.delivered && pullConversation) {
renderPullConversation(pullConversation.append(admission.delivered));
}
qs('#pull-comment').value = '';
pullAttachmentController.clear();
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
(admission.background ? 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.');
}
return;
}
preparedBody = body;
const comment = await pullController.comment(item, preparedBody);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
pullAttachmentController.clear();
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error) && !pullAttachmentController.state()) {
try {
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({
kind:'pull-comment', repository:item.repository, number:item.number,
body:preparedBody ?? body, operationId,
});
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
refreshMyWorkView();
qs('#pull-comment-status').textContent = admission.background ?
'Queued for sync when the connection returns.' :
'Saved for next launch; background delivery unavailable.';
return;
} catch (admissionError) {
error = admissionError;
}
}
qs('#pull-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#pull-comment').focus();
} finally {
pullAttachmentController.setBusy(false);
button.disabled = false;
}
});
qs('#merge-pull').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
const merging = selectedPull;
const button = qs('#merge-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = merging.key + ' merged.';
}
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
button.disabled = false;
button.focus();
}
});
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
});
qs('#load-older-update-comments').addEventListener('click', async () => {
const button = qs('#load-older-update-comments');
const panel = qs('#update-sheet .update-sheet-panel');
const previousHeight = panel.scrollHeight;
const previousTop = panel.scrollTop;
button.disabled = true;
await notificationReader.loadOlder();
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
button.disabled = false;
});
qs('#update-reply').addEventListener('input', event => {
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
});
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
updateReplyAttachmentController.setBusy(true);
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
qs('#send-update-reply').disabled = false;
updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
}
});
qs('#send-update-reply-read-next').addEventListener('click', async () => {
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
const button = qs('#send-update-reply-read-next');
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
updateReplyAttachmentController.setBusy(true);
qs('#update-reply-status').textContent = 'Replying, then marking read…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
if (result?.accepted) updateReplyAttachmentController.clear();
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
if (result?.delivery === 'posted' && result?.next) {
notificationUndo.offer(item, result.next.items);
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
} finally {
button.disabled = false;
sendButton.disabled = false;
updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
const result = await notificationReader.markReadAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
} finally {
qs('#mark-update-read-next').disabled = false;
}
});
qs('#acknowledge-update-next').addEventListener('click', async () => {
const button = qs('#acknowledge-update-next');
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
const restored = await notificationUndo.run();
button.disabled = restored;
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
});
qs('#refresh-review-checks').addEventListener('click', async () => {
if (!selectedReview || offlineReview) return;
const item = selectedReview;
const button = qs('#refresh-review-checks');
button.disabled = true;
qs('#review-checks-summary').textContent = 'Refreshing…';
try {
const detail = await reviewController.loadChecks(item);
if (selectedReview !== item) return;
if (selectedReviewHead && detail.head_sha !== selectedReviewHead) {
qs('#review-sheet-status').textContent = 'New commits detected. Reload the review before submitting feedback.';
qs('#submit-review').disabled = true;
}
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail);
} catch (error) {
if (selectedReview === item) qs('#review-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-review').addEventListener('click', () => {
if (progress) openNextUnreviewed(progress.snapshot());
});
qs('#review-wrap-lines').addEventListener('click', () => {
const next = !wrapPreference.snapshot().wrapped;
applyReviewWrap(wrapPreference.setWrapped(next));
});
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#save-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
const body = qs('#review-inline-body').value.trim();
if (!body) {
qs('#review-inline-body').focus();
return;
}
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
activeInlineTarget.classList.add('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#delete-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
activeInlineTarget.classList.remove('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#cancel-inline-comment').addEventListener('click', () => {
const target = activeInlineTarget;
closeInlineComposer();
target?.focus();
});
qs('#submit-review').addEventListener('click', async () => {
if (!draft || !selectedReview || !selectedReviewHead) return;
const snapshot = draft.snapshot();
const labels = { approve: 'Approve', request_changes: 'Request changes' };
if (labels[snapshot.decision] && !window.confirm(
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
)) return;
const button = qs('#submit-review');
button.disabled = true;
if (offlineReview) {
const queuedTodayReview = reviewingActiveTodayItem();
qs('#review-submit-status').textContent = 'Queueing review safely…';
try {
await authoredOutbox.enqueueDurably({
kind: 'pull-review',
repository: selectedReview.repository,
number: selectedReview.number,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
decision: snapshot.decision,
expectedHeadSha: selectedReviewHead,
comments: snapshot.comments,
draftKey: draft.storageKey,
progressKey: progress?.storageKey || '',
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
});
if (queuedTodayReview) {
const advanced = completeTodayItem(selectedReview, {
successMessage: 'Review queued. Next Today item opened.',
failureMessage: 'Review queued, but Today still needs completion.',
});
if (!advanced) {
qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.';
}
} else {
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
button.disabled = false;
button.focus();
}
return;
}
qs('#review-submit-status').textContent = 'Submitting review…';
try {
const item = selectedReview;
const progressSnapshot = progress?.snapshot() || { reviewed: [] };
const result = await reviewController.submit(selectedReview, {
decision: snapshot.decision,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
expected_head_sha: selectedReviewHead,
comments: snapshot.comments,
});
const canContinueToMerge = createReviewController.prepareMergeContinuation({
storage: localStorage,
item,
headSha: selectedReviewHead,
reviewed: progressSnapshot.reviewed,
decision: snapshot.decision,
});
draft.clear();
progress?.clear();
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
if (progress) showReviewProgress(progress.snapshot());
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
if (canContinueToMerge) {
qs('#continue-review-to-merge').hidden = false;
qs('#continue-review-to-merge').focus();
} else {
await load();
if (workSession.active()) await runTodayTransition('complete');
else button.focus();
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
button.disabled = false;
button.focus();
}
});
qs('#continue-review-to-merge').addEventListener('click', () => {
const item = selectedReview;
if (!item) return;
workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true });
});
qs('#copy-review-feedback').addEventListener('click', async () => {
if (!draft || !selectedReview || reviewHandoffPending) return;
reviewHandoffPending = true;
qs('#copy-review-feedback').disabled = true;
const fallback = qs('#review-copy-fallback');
const directLink = qs('#review-handoff-link');
fallback.hidden = true;
directLink.hidden = true;
let handoffWindow = null;
try {
const result = await createReviewController.copyAndContinue({
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
url: selectedReview.url,
copy: text => navigator.clipboard.writeText(text),
open: () => {
handoffWindow = window.open('about:blank', '_blank');
if (handoffWindow) handoffWindow.opener = null;
return handoffWindow;
},
fallback: text => {
fallback.value = text;
fallback.hidden = false;
fallback.focus();
fallback.select();
},
});
if (result.opened) {
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
} else {
directLink.hidden = false;
qs('#review-handoff-status').textContent = result.copied ?
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
}
} finally {
reviewHandoffPending = false;
qs('#copy-review-feedback').disabled = false;
}
});
const contextPoller = createContextPoller({
fetchContext: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot,
onError: async error => {
await handleContextError(error);
setEventStreamStatus('Update failed · showing last activity');
},
isHidden: () => document.hidden,
intervalMs: 8000,
});
function load() { return contextPoller.refresh({ force: true }); }
const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status');
const retryOfflineStorage = qs('#retry-offline-storage');
const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status');
async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled();
keepWorkOffline.disabled = !offlineStorageReady;
retryOfflineStorage.hidden = offlineStorageReady;
const saved = await offlineWorkStore.load();
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
}
async function updateDeliveryReceiptControls(message) {
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
if (!supported) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
return;
}
if (!confirmedOwnerLogin) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
return;
}
deliveryReceipts.checked = Notification.permission === 'granted' &&
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
}
function setOfflineWorkMode(value) {
offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
}
async function hydrateOfflineWork(mode = 'offline') {
const saved = await offlineWorkStore.load();
if (!saved) return false;
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
planningOwnerLogin = confirmedOwnerLogin;
interruptionPrompt.restore();
updatePlanningAvailability();
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
lastNotifications = saved.notifications;
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {};
lastContextSnapshot = saved;
hasContextSnapshot = true;
paintMyWork(saved);
setOfflineWorkMode(true);
const savedLabel = fmt(saved.saved_at);
if (outage) {
qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.';
offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.';
setStatus('Outage · saved snapshot');
} else {
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
setStatus('Offline · saved snapshot');
}
offlineStatus.hidden = false;
return true;
}
async function showOfflineStatus() {
activeFlushLogin = '';
offlineStatus.hidden = false;
setStatus('Offline');
if (!hasContextSnapshot) await hydrateOfflineWork();
}
function reconnectLiveData() {
offlineStatus.hidden = true;
setOfflineWorkMode(false);
setStatus('Reconnecting…');
contextPoller.refresh({ force: true }).then(() => {
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
});
}
async function setOfflineWorkEnabled(enabled) {
keepWorkOffline.checked = enabled;
offlineWorkStore.setEnabled(enabled);
if (enabled && liveMode && lastContextSnapshot) {
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination });
if (admitted === false) {
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
return false;
}
}
if (!enabled) await offlineWorkStore.clear();
await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (enabled) warmTodayOffline();
else offlineToday.cancel();
return offlineWorkStore.enabled();
}
keepWorkOffline.addEventListener('change', async () => {
await setOfflineWorkEnabled(keepWorkOffline.checked);
});
deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked;
if (enabled && Notification.permission !== 'granted') {
enabled = await Notification.requestPermission() === 'granted';
}
deliveryReceipts.checked = enabled;
if (backgroundIssueSync && confirmedOwnerLogin) {
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
}
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
});
qs('#clear-offline-work').addEventListener('click', async () => {
await offlineWorkStore.clear();
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
await updateOfflineWorkControls('Offline work data cleared.');
});
retryOfflineStorage.addEventListener('click', async () => {
retryOfflineStorage.disabled = true;
offlineWorkStatus.textContent = 'Retrying offline saving…';
offlineStorageReady = await offlineWorkStore.retry();
retryOfflineStorage.disabled = false;
if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination });
}
await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
'Offline saving unavailable · online work remains live.');
});
qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
);
await updateOfflineWorkControls();
updateDeliveryReceiptControls();
if (!navigator.onLine) await showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
qs('#refresh').addEventListener('click', load);
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
});
qs('#today-readiness-close').addEventListener('click', () => closeTodayReadiness());
qs('#today-readiness-sheet').addEventListener('click', event => {
if (event.target === qs('#today-readiness-sheet')) closeTodayReadiness();
});
qs('#today-readiness-next').addEventListener('click', () => todayReadiness.startNextReady());
qs('#today-readiness-anyway').addEventListener('click', () => todayReadiness.workAnyway());
qs('#today-readiness-retry').addEventListener('click', () => todayReadiness.retry());
qs('#today-readiness-plan').addEventListener('click', () => {
closeTodayReadiness();
openPlanToday(qs('#plan-today'));
});
qs('#plan-today-available').addEventListener('change', event => {
planToday.setCapacity(Number(event.currentTarget.value));
qs('#plan-today-error').textContent = '';
renderPlanToday();
});
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
function commitPlanToday(start, button) {
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
if (result === 'saved') {
button.dataset.confirmOverCapacity = '';
taskOverlayHistory.leave();
} else if (result === 'confirm-over-capacity') {
button.dataset.confirmOverCapacity = 'true';
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
button.textContent = start ? 'Save over capacity & start' : 'Save over capacity';
} else {
qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
}
}
qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget));
qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget));
qs('#start-work-session').addEventListener('click', () => {
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
if (!sessionItems.length) {
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
if (selectedWorkFilter === 'today') runTodayTransition('start');
else workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);
document.querySelectorAll('[data-work-session-next]').forEach(button =>
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
);
document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button =>
button.addEventListener('click', () => timerView.toggle())
);
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
button.addEventListener('click', () =>
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
)
);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#load-more-work').addEventListener('click', async () => {
if (selectedWorkFilter === 'agenda') {
await completeAgendaIssues();
return;
}
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
if (!stream || !lastContextSnapshot) return;
const button = qs('#load-more-work');
button.disabled = true;
const existing = stream === 'issue' ?
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
try {
const loaded = await workPager.loadMore(stream, existing);
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
else button.focus();
} finally {
button.disabled = false;
}
});
qs('#select-work').addEventListener('click', () => {
if (notificationSelection.snapshot().active) notificationSelection.cancel();
workSelection.start();
document.querySelector('[data-select-work-id]')?.focus();
});
qs('#cancel-work-selection').addEventListener('click', () => {
workSelection.cancel();
qs('#select-work').focus();
});
qs('#batch-add-today').addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const result = todayWork.addMany(selectedItems);
if (result.status === 'added') {
result.ids.forEach(id => todaySync.enqueue('add', id));
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
workSelection.cancel();
} else {
qs('#my-work-action-status').textContent = result.status === 'full' ?
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
(result.status === 'exists' ? 'Every selected item is already in Today.' :
'Could not save Today on this device. Nothing was added; selection unchanged.');
}
refreshMyWorkView();
});
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const until = laterWork.presetUntil(button.dataset.batchDefer);
const result = laterWork.deferMany(selectedItems, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') workSelection.cancel();
refreshMyWorkView();
}));
qs('#select-updates').addEventListener('click', () => {
notificationSelection.start();
qs('#update-selection-status').textContent = '0 of 50 selected';
document.querySelector('[data-select-notification-id]')?.focus();
});
qs('#cancel-update-selection').addEventListener('click', () => {
notificationSelection.cancel();
qs('#select-updates').focus();
});
document.querySelectorAll('[data-defer-selected]').forEach(button => button.addEventListener('click', () => {
const selection = notificationSelection.snapshot();
const ids = new Set(selection.ids);
const selectedUpdates = lastMyWork.filter(item => item.has_update && ids.has(item.notification_id));
const until = laterWork.presetUntil(button.dataset.deferSelected);
const result = laterWork.deferMany(selectedUpdates, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selection.count + ' updates deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') notificationSelection.cancel();
refreshMyWorkView();
}));
qs('#bulk-mark-read').addEventListener('click', async () => {
const selection = notificationSelection.snapshot();
if (!selection.ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
renderMyWork();
return;
}
bulkConfirmationPending = false;
bulkMarkPending = true;
renderMyWork();
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
if (result) {
const marked = new Set(result.marked);
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
notificationSelection.retain(result.failed);
if (!result.failed.length) notificationSelection.cancel();
}
bulkMarkPending = false;
renderMyWork();
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
});
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
selectWorkQueue(button.dataset.workFilter);
});
});
function updateQueueFinder(matches, loaded, incomplete) {
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
qs('#queue-find-label').textContent = queueLabel;
qs('#clear-queue-find').hidden = !queueFindQuery;
qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
qs('#queue-find-status').textContent = queueFindQuery ?
(matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
(incomplete ? ' among loaded work.' : '.')) : '';
}
qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
qs('#queue-find-input').addEventListener('input', event => {
queueFindQuery = event.target.value;
renderMyWork();
});
qs('#clear-queue-find').addEventListener('click', () => {
queueFindQuery = '';
qs('#queue-find-input').value = '';
renderMyWork();
qs('#queue-find-input').focus();
});
qs('#search-older-work').addEventListener('click', () => {
const notificationButton = qs('#load-more-notifications');
const workButton = qs('#load-more-work');
const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
if (!target || target.hidden || target.disabled) return;
qs('#queue-find-status').textContent = 'Searching older ' +
(selectedWorkFilter === 'update' ? 'updates…' : 'work…');
target.click();
});
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
const button = qs('[data-work-filter="' + filter + '"]');
if (!button) return false;
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
selectedWorkFilter = filter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
if (workSelection.snapshot().active) workSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {
console.warn('Filter save failed', 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();
if (selectedWorkFilter === 'agenda') completeAgendaIssues();
if (!preserveRoute) workRoute.queue(filter);
return true;
}
function openWorkQueueRoute(filter) {
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value;
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
catch (e) { console.warn('Milestone save failed', e); }
renderMyWork();
if (workSession.active()) workSession.reconcile();
});
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) {
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
await issueCaptureFeatures.load('push-notifications');
const controller = createPushNotifications({
control:qs('#push-updates'),
status:qs('#push-update-status'),
deadlineControl:qs('#push-deadlines'),
deadlineStatus:qs('#push-deadline-status'),
notification:window.Notification,
serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson,
});
await controller.init();
return controller;
}).catch(error => {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
console.warn('Push notifications unavailable', error);
return null;
});
} else {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
}
let deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
deferredInstallPrompt = event;
});
let deviceSetup = null;
async function ensureDeviceSetup() {
if (deviceSetup) return deviceSetup;
await issueCaptureFeatures.load('device-setup');
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const installApp = createInstallApp({
window, initialPrompt:deferredInstallPrompt,
card:qs('#install-app-card'), installButton:qs('#install-app'),
dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'),
status:qs('#install-app-status'), storage:localStorage,
isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true,
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
});
installApp.start();
deviceSetup = createMobileDeviceSetup.mount({
document, installApp, promptStorage:localStorage,
offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true),
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
qs('#push-updates').checked = true;
await controller.change();
},
});
await deviceSetup.start();
return deviceSetup;
}
qs('#open-device-setup').addEventListener('click', async event => {
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
});
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
contextPoller.start();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) deviceSetup?.render();
});
/* Widgets */
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();