stackchain-dashboard/frontend/dashboard.js
timmy 170828b308
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 58s
CI / release-candidate (pull_request) Has been skipped
feat: promote checklist steps to related issues (Closes #921)
2026-08-15 23:40:17 +00:00

7351 lines
349 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('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
],
});
mobileComposerViewport.start();
[
[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();
}
let mobileQueueCounts = {};
function openFiledFollowUp() {
selectMobileQueue('filed');
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
if (!target) {
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
return 'empty';
}
const index = lastMyWork.indexOf(target.item);
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
}
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
openFiled: openFiledFollowUp,
selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: message => { qs('#my-work-action-status').textContent = message; },
getCounts: () => mobileQueueCounts,
openFindWork: () => qs('#find-work').click(),
});
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
getEligibleCount: () => activeMyWork.length,
queueLauncher: mobileQueueLauncher,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
findWork: () => qs('#find-work').click(),
});
const mobileStartDay = createMobileStartDay({
getCounts: () => mobileQueueCounts,
getLogin: () => confirmedOwnerLogin,
openQueue: name => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
return name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name);
},
onHandoff: current => {
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) sheet.showModal();
qs('#mobile-start-day-action').focus();
},
elements: {
summary: qs('#mobile-start-day-summary'),
phases: qs('#mobile-start-day-phases'),
action: qs('#mobile-start-day-action'),
finish: qs('#finish-mobile-start-day'),
},
});
mobileStartDay.start();
qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish());
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
mobileStartDay.render();
const next = mobileQueueLauncher.recommend();
qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : '');
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
const row = qs('[data-mobile-queue="' + next.name + '"]');
row.setAttribute('data-recommended', 'true');
qs('#mobile-queue-sheet').showModal();
row.focus();
}
let keptUpdateIdentities = [];
function showUpdateTriageOutcome(outcome) {
const box = qs('#mobile-update-outcome');
const review = qs('#review-kept-updates');
keptUpdateIdentities = outcome.keptIdentities;
box.hidden = false;
qs('#mobile-update-outcome-summary').textContent = outcome.reviewed + ' reviewed · ' + outcome.resolved +
' marked read · ' + outcome.kept + ' kept unread';
review.hidden = !outcome.kept;
review.textContent = 'Review ' + outcome.kept + ' kept unread';
if (!outcome.kept && mobileStartDay.completePhase('update')) return;
showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept);
if (outcome.kept) review.focus();
}
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
queueSheet: qs('#mobile-queue-sheet'),
queueClose: qs('#close-mobile-queues'),
queueRows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
queueCounts: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
),
queueBadge: qs('#mobile-queue-count'),
deadlineBadge: qs('#mobile-deadline-count'),
onSelectQueue:name => 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 selectedFiledView = 'needs-review';
let selectedWorkMilestone = 'all';
let queueFindQuery = '';
let savedWorkFilter = null;
let launchFilterResolved = false;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
selectedWorkFilter = savedFilter;
savedWorkFilter = savedFilter;
launchFilterResolved = true;
}
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {}
let lastMyWork = [];
let lastDrafts = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let workPagination = {};
let agendaChecking = false;
let agendaReplan = null;
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let offlineReview = false;
let selectedUpdate = null;
let selectedUpdateDetail = null;
let followUpSourceUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueOffline = false;
let selectedIssueDetail = null;
let dismissedChecklistBody = null;
let issueBlockerCandidates = [];
let issueBlockerSearchTimer = null;
let issueConversation = null;
let issueTrigger = null;
let issueEditHistoryActive = false;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let pendingIssueFilingIntent = 'create-and-assign';
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let reviewHandoffSubmitted = false;
let activeInlineTarget = null;
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let activeFlushLogin = '';
const completedFiledReview = createCompletedFiledReview({
storage: localStorage,
getLogin() { return planningOwnerLogin; },
});
const filedHistoryTabs = createFiledHistoryTabs({
root:qs('#filed-history-tabs'), review:completedFiledReview,
onSelect:view => { selectedFiledView = view; renderMyWork(); },
});
let completedFiledSyncFlight = null;
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
let rolloverReviewPlan = null;
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayRollover = createTodayRollover();
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();
if (updateTriage.active()) updateTriage.reconcile();
}
});
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
throw error;
}
return payload;
}
function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight;
const pending = completedFiledReview.pending();
const options = pending.length ? {
method:'POST',
headers:{Accept:'application/json', 'Content-Type':'application/json'},
body:JSON.stringify({ receipts:pending }),
} : { headers:{Accept:'application/json'} };
completedFiledSyncFlight = fetchReviewJson('api/v1/completed-filed-reviews', options)
.then(snapshot => {
const changed = completedFiledReview.adopt(snapshot);
if (lastContextSnapshot && changed) {
lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
}
if (pending.length) qs('#my-work-action-status').textContent =
'Completed Filed review saved to account.';
return true;
})
.catch(() => {
if (pending.length) qs('#my-work-action-status').textContent =
'Acknowledged on this device · sync pending.';
return false;
})
.finally(() => { completedFiledSyncFlight = null; });
return completedFiledSyncFlight;
}
let commentActions = { isOwned: () => false, wire: () => {} };
function loadMentionCandidates(repository, query) {
return fetchReviewJson(
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
{headers:{Accept:'application/json'}},
);
}
const issueMentions = 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,
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
});
function overdueAgendaItems() {
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
}
function renderAgendaReplan() {
const state = agendaReplan.snapshot();
qs('#agenda-replan-controls').hidden = !state.active;
if (!state.active) return;
qs('#agenda-replan-progress').textContent =
'Overdue deadline ' + (state.index + 1) + ' of ' + state.total + ' · ' + (agendaReplan.current()?.key || '');
}
function openAgendaReplanCurrent() {
const item = agendaReplan.current();
if (!item) {
closeIssueSheet(false);
renderAgendaReplan();
qs('#my-work-action-status').textContent = 'Overdue sweep complete. Agenda updated.';
return;
}
renderAgendaReplan();
openIssueSheet(item, qs('#start-agenda-replan'));
}
agendaReplan = createAgendaReplan({
update: async (item, dueDate) => {
const confirmed = await issueController.updateDueDate(item, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, item.repository, item.number, confirmed.due_date
);
paintMyWork(lastContextSnapshot);
return confirmed;
},
});
qs('#start-agenda-replan').addEventListener('click', () => {
const overdue = overdueAgendaItems();
agendaReplan.start(overdue);
openAgendaReplanCurrent();
});
async function runAgendaReplan(action) {
const result = await action();
if (!result.ok) {
qs('#agenda-replan-progress').textContent = result.error + ' Current deadline retained; retry.';
return;
}
openAgendaReplanCurrent();
}
qs('#agenda-replan-keep').addEventListener('click', () => runAgendaReplan(() => agendaReplan.keep()));
qs('#agenda-replan-tomorrow').addEventListener('click', () => runAgendaReplan(() => agendaReplan.tomorrow()));
qs('#agenda-replan-choose').addEventListener('click', () =>
runAgendaReplan(() => agendaReplan.choose(qs('#agenda-replan-date').value))
);
qs('#agenda-replan-cancel').addEventListener('click', () => {
agendaReplan.cancel();
closeIssueSheet(false);
renderAgendaReplan();
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
});
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'),
tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'),
note: qs('#create-issue-evidence-note'),
noteLabel: qs('#create-issue-evidence-note-label'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
editor: {
document,
edit: qs('#edit-create-issue-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'),
redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'),
arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'),
reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'),
apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
});
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
let checklistPromotion = null;
let issueOwnerPicker = null;
let issueTemplatePicker = null;
let issueFilingMetadata = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
attachmentStore: unfiledAttachmentStore,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
createUnfiledDraftSync(
unfiledCaptures, fetchReviewJson
);
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
review:r=>loadIssueFilingMetadata(r.repository,r)
.then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates),
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
backgroundIssueSync = createBackgroundIssueSync({
store: backgroundIssueStore, fetchJson: fetchReviewJson,
});
backgroundIssueSync.requestSync = async () => {
if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable');
const registration = await navigator.serviceWorker.ready;
if (!registration.sync) throw new Error('Background Sync unavailable');
await registration.sync.register('stackchain-issue-outbox-v1');
};
}
const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const filingReview = createIssueFilingReview({
sheet: qs('#issue-filing-review'),
confirmButton: qs('#confirm-issue-filing'),
backButton: qs('#back-to-issue-edit'),
evidenceList: qs('#issue-filing-review-evidence'),
evidenceEmpty: qs('#issue-filing-review-evidence-empty'),
evidencePreview: qs('#issue-filing-review-evidence-preview'),
evidenceImage: qs('#issue-filing-review-evidence-image'),
evidencePosition: qs('#issue-filing-review-evidence-position'),
evidenceFilename: qs('#issue-filing-review-evidence-filename'),
evidenceNote: qs('#issue-filing-review-evidence-note'),
repository: qs('#issue-filing-review-repository'),
intent: qs('#issue-filing-review-intent'),
issueType: qs('#issue-filing-review-template'),
title: qs('#issue-filing-review-title'),
body: qs('#issue-filing-review-body'),
metadata: qs('#issue-filing-review-metadata'),
blockerList: qs('#issue-filing-review-blockers'),
status: qs('#issue-filing-review-status'),
document,
createObjectURL: blob => URL.createObjectURL(blob),
revokeObjectURL: url => URL.revokeObjectURL(url),
onConfirm: admitReviewedIssue,
});
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
mergeChecklistConflict: mergeChecklistConflict,
});
const 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 appShortcut = mobileAppShortcuts.createController({
search: location.search,
continueWork: () => mobileWorkEntry.open(),
newIssue: () => openCreateIssueSheet(),
agenda: () => openAgendaSession(),
});
const sharedLaunch = {
title: shareParams.get('title') || '',
text: shareParams.get('text') || '',
url: shareParams.get('url') || '',
};
const sharedImageMarker = shareParams.get('shared') || '';
let sharedImageHandled = false;
const commentActionFeatures = createFeatureLoader({
document,
urls: {
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
},
});
const commentActionHydrator = createConversationActionHydrator({
load: () => commentActionFeatures.load('comment-actions'),
activate: () => {
commentActions = createCommentActions({
fetchJson: fetchReviewJson,
getLogin: () => confirmedOwnerLogin,
confirmDelete: message => window.confirm(message),
});
return commentActions;
},
});
const issueCaptureFeatures = createFeatureLoader({
document,
urls: {
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
},
});
let sharedLaunchState = null;
let sharedLaunchHandled = false;
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 });
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
issueTemplatePicker = issueCapture.bindTemplatePicker({
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
status:qs('#create-issue-template-status'), document,
getRepository:()=>qs('#create-issue-repository').value,
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
}, {
getDraft:currentIssueCaptureDraft,
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
});
issueFilingMetadata = issueCapture.bindFilingMetadata({
document,
labelList:qs('#create-issue-label-list'),
labelStatus:qs('#create-issue-label-status'),
milestoneSelect:qs('#create-issue-milestone'),
milestoneStatus:qs('#create-issue-milestone-status'),
templateStatus:qs('#create-issue-template-status'),
getRepository:()=>qs('#create-issue-repository').value,
}, issueTemplatePicker);
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
checklistPromotion = createIssueCapture.createChecklistPromotion({
issueController, issueCapture,
clearAttachments:()=>createIssueAttachmentController.clear(),
onLinked:(promotion, confirmed) => {
if (selectedIssue?.repository === promotion.item.repository &&
selectedIssue?.number === promotion.item.number) {
applyIssueContent(promotion.item, promotion.detail, confirmed);
qs('#issue-sheet').classList.add('open');
}
},
onStatus:message=>{ qs('#my-work-action-status').textContent = message; },
});
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
sharedLaunchHandled = true;
}
});
}
if (Object.values(sharedLaunch).some(Boolean) || sharedImageMarker) await ensureIssueCapture();
const pullWorkflowFeatures = createFeatureLoader({
document,
urls: {
'pull-workflow': document.querySelector('meta[name="stackchain-feature-pull-workflow"]')?.content || '',
},
});
let pullController = null;
async function ensurePullWorkflow(trigger = null) {
return await pullWorkflowFeatures.run('pull-workflow', {
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
}, () => {
if (!pullController) {
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
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 muteNotification(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/mute', { method: 'POST', headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || 'Muting future updates failed.');
error.muted = payload.muted === true;
throw error;
}
return payload;
}
async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
return payload;
}
async function fetchNotificationPage(page) {
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
return payload;
}
async function fetchNotificationDetail(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
return payload;
}
async function fetchNotificationConversation(notificationId, page) {
const pageQuery = Number.isInteger(page) ? '&page=' + encodeURIComponent(page) : '';
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/conversation?limit=20' + pageQuery, {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
return payload;
}
async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
return payload;
}
async function postNotificationReply(notificationId, body, operationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({ body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
markRead: markNotificationsRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationSelection = createNotificationSelection({
limit: 50,
onChange: () => {
bulkConfirmationPending = false;
renderMyWork();
},
});
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
lastNotifications = items;
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
onPagination: pagination => {
notificationPagination = pagination;
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !pagination.has_more;
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const workPager = createWorkPager({
load: fetchWorkPage,
onItems: (stream, items) => {
if (!lastContextSnapshot) return;
if (stream === 'issue' || stream === 'filed') lastContextSnapshot.issues = items;
else lastContextSnapshot.pull_requests = items;
paintMyWork(lastContextSnapshot);
},
onPagination: pagination => {
workPagination = pagination;
updateWorkPaginationControls();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
authoredOutbox,
onStatus: message => { qs('#update-reply-status').textContent = message; },
});
const updateOwnership = createUpdateOwnership({
available: () => createAndStart.available(),
claim: item => fetchReviewJson(
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number) + '/claim',
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
if (result === 'added') {
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
return result;
},
start: item => {
const outcome = createAndStart.complete(item);
if (outcome === 'started') openRoutedWork(item, qs('#update-ownership-start'));
return outcome;
},
recover: item => openRoutedWork(item, qs('#update-ownership-start')),
onClaimed: item => {
if (!lastContextSnapshot) return;
const issues = (lastContextSnapshot.issues || []).filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number
);
lastContextSnapshot = { ...lastContextSnapshot, issues:issues.concat(item) };
paintMyWork(lastContextSnapshot);
},
onState: state => {
const button = qs('#update-ownership-action');
button.hidden = state.action === 'hidden';
button.disabled = state.busy;
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
onStartState: state => {
const button = qs('#update-ownership-start');
button.hidden = state.action === 'hidden';
button.disabled = state.busy;
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
});
const notificationUndo = createDashboardNotificationUndo({
restore: requestNotificationUnread,
getItems: () => lastMyWork,
setItems: items => { lastMyWork = items; },
getNotifications: () => lastNotifications,
setNotifications: items => { lastNotifications = items; },
refresh: refreshMyWorkView,
select: qs,
});
const updateReadPosition = createUpdateReadPosition({
panel: qs('#update-sheet .update-sheet-panel'),
jump: qs('#jump-update-new-activity'),
});
const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') });
const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') });
const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') });
const workDetailIdentity = (kind, item) => kind + ':' + item.key;
qs('#jump-update-new-activity').addEventListener('click', () => updateReadPosition.jump());
qs('#focus-update-reply').addEventListener('click', () => {
const composer = qs('#update-reply');
composer.scrollIntoView({ behavior:'smooth', block:'center' });
qs('#update-reply').focus({ preventScroll:true });
});
qs('#toggle-update-more').addEventListener('click', event => {
const more = qs('.update-more-actions');
more.open = !more.open;
event.currentTarget.setAttribute('aria-expanded', String(more.open));
if (more.open) more.scrollIntoView({ behavior:'smooth', block:'end' });
});
const updateDecision = createUpdateDecisionTransaction({
controls: [
qs('#keep-update-unread'), qs('#mark-update-read-next'),
qs('#review-update-now'), qs('#focus-update-reply'), qs('#toggle-update-more'),
],
status: qs('#update-gesture-status'),
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
getScope: () => confirmedOwnerLogin,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
acknowledge: acknowledgeNotification,
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
updateReplyAttachmentController.clear();
}
selectedUpdate = item;
selectedUpdateDetail = null;
updateReadPosition.open(String(item.notification_id));
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('.update-more-actions').open = false;
qs('#toggle-update-more').setAttribute('aria-expanded', 'false');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
qs('#update-comments').textContent = '';
qs('#update-conversation-status').textContent = '';
qs('#retry-update-conversation').hidden = true;
qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
qs('#open-update-gitea').href = item.url || '#';
qs('#update-reply').value = notificationReplier.loadDraft(item);
qs('#update-reply-status').textContent = '';
qs('#send-update-reply').disabled = false;
qs('#send-update-reply-read-next').disabled = false;
qs('#acknowledge-update-next').hidden = true;
qs('#mute-update-next').hidden = true;
qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true;
qs('#review-update-now').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: async detail => {
selectedUpdateDetail = detail;
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
qs('#mute-update-next').hidden = !detail.mute_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
qs('#review-update-now').hidden = !updateReviewHandoff.eligible(detail, selectedUpdate);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true;
},
onConversation: renderUpdateConversation,
onConversationStatus: message => {
qs('#update-conversation-status').textContent = message;
qs('#retry-update-conversation').hidden = !message.startsWith('Conversation temporarily unavailable.');
},
onItems: items => {
const readId = selectedUpdate?.notification_id;
lastMyWork = items;
if (Number.isInteger(readId)) {
lastNotifications = lastNotifications.filter(item => item.id !== readId);
}
refreshMyWorkView();
},
onStatus: message => {
qs('#update-sheet-status').textContent = message;
if (message === 'Update ready.' && updateTriage.active() && !offlineWorkMode) {
notificationReader.prefetch(updateTriage.next());
}
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
if (message === 'Inbox cleared.') {
qs('#my-work-action-status').textContent = message;
if (!mobileStartDay.completePhase('update')) showMobileQueueCompletion('Updates');
}
},
onClose: () => closeUpdateSheet(false),
});
const updateTriage = createUpdateTriageSession({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)),
onOpen: item => notificationReader.open(
item,
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null
),
onProgress: state => {
const progress = qs('#update-triage-progress');
progress.hidden = false;
progress.textContent = 'Update ' + state.index + ' of ' + state.total +
(state.reason ? ' · ' + state.reason : '');
},
onFinish: outcome => {
notificationReader.prefetch();
qs('#update-triage-progress').hidden = true;
showUpdateTriageOutcome(outcome);
},
});
const updateReviewHandoff = createUpdateReviewHandoff({
openReview: item => {
qs('#update-sheet').classList.remove('open');
reviewHandoffSubmitted = false;
openReviewSheet(item, qs('#review-update-now'),
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null);
},
restoreUpdate: () => {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
qs('#update-sheet').classList.add('open');
qs('#review-update-now').focus();
},
admitRead: notificationId => offlineWorkMode ?
notificationReadOutbox.enqueueDurably(notificationId) : markNotificationRead(notificationId),
advance: source => {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
notificationReader.acceptReadAndNext(lastMyWork, source);
if (updateTriage.active()) updateTriage.acceptCompleted();
reviewHandoffSubmitted = false;
},
});
qs('#review-update-now').addEventListener('click', () => updateReviewHandoff.begin(selectedUpdate));
qs('#review-kept-updates').addEventListener('click', () => {
qs('#mobile-queue-sheet').close();
updateTriage.reviewKept(keptUpdateIdentities);
});
const updateTriageLauncher = createUpdateTriageLauncher({
selectUpdates: () => selectMobileQueue('update'),
discover: () => api('api/v1/notifications/snapshot'),
applySnapshot: snapshot => {
lastNotifications = snapshot.items || [];
notificationPager.reset({page:1, total:snapshot.total || 0, has_more:false});
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
isUpdatesSelected: () => selectedWorkFilter === 'update',
hasMore: () => Boolean(notificationPagination.has_more),
hasCheckpoint: () => updateTriage.resumable(),
resume: () => updateTriage.resume(),
start: () => updateTriage.start(),
announce: message => { qs('#my-work-action-status').textContent = message; },
});
function openUpdateTriage() {
return updateTriageLauncher.open();
}
function routedWorkItem(item) {
if (item?.has_update && item.kind === 'update') {
return { ...item, kind:'update' };
}
return { ...item, kind:item?.is_review ? 'review' : item?.kind };
}
function openRoutedWork(item, trigger, options = {}) {
if (!item) return;
if (offlineWorkMode) {
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
const savedUpdate = item.kind === 'update' && item.has_update;
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
return;
}
if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger, savedDetail);
} else if (item.is_review) {
reviewTrigger = trigger;
openReviewSheet(item, trigger, savedDetail);
} else if (item.kind === 'pull') {
pullTrigger = trigger;
openPullSheet(item, trigger, savedDetail);
} else if (savedUpdate) {
updateTrigger = trigger;
notificationReader.open(item, savedDetail).then(opened => {
if (opened && selectedUpdate === item) {
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
}
});
} else {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
}
return;
}
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
else if (item.is_review) reviewTrigger = trigger;
else if (item.kind === 'issue') issueTrigger = trigger;
else if (item.kind === 'pull') pullTrigger = trigger;
workRoute.open(routedWorkItem(item), options);
}
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' || item.kind === 'filed') 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 agendaSessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
key: 'stackchain.agenda-session.v1',
onError: () => {
qs('#my-work-action-status').textContent =
'Agenda progress could not be saved on this device. You can keep working.';
},
});
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();
const startWorkSession = qs('#start-work-session');
startWorkSession.hidden = active;
startWorkSession.textContent = selectedWorkFilter === 'agenda' ?
(agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda') : 'Start work';
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
!todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
}
const workSession = createWorkSession({
getItems: () => selectedWorkFilter === 'today' ? todayMyWork :
selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' :
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
checkpointedEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem,
onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
timerView.update(state, runway);
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
button.disabled = !state.can_previous;
});
document.querySelectorAll('[data-work-session-next]').forEach(button => {
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
});
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
button.hidden = !workSession.checkpointed();
});
},
onFinish: () => {
updateWorkSessionActions();
if (selectedWorkFilter === 'agenda') {
closeOpenWorkSheets();
if (mobileStartDay.completePhase('agenda')) return;
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Agenda complete.';
return;
}
todayRecapView.finish(selectedWorkFilter);
},
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
const agendaSessionLauncher = createAgendaSessionLauncher({
selectAgenda: () => selectMobileQueue('agenda'),
discover: completeAgendaIssues,
isAgendaSelected: () => selectedWorkFilter === 'agenda',
hasMore: () => Boolean(workPagination.issue?.has_more),
hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
resume: () => workSession.resume(),
start: () => workSession.start(),
announce: message => { qs('#my-work-action-status').textContent = message; },
});
function openAgendaSession() {
return agendaSessionLauncher.open();
}
let todayReadinessTrigger = null;
let todayReadinessBlockerFocus = null;
let searchPreviewReturnKind = null;
function inspectTodayDependencies(item) {
if (offlineWorkMode) {
const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim();
const detail = offlineWorkStore.loadDetail(login, item);
return Promise.resolve({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
});
}
const [owner, repo] = String(item.repository || '').split('/');
return api('api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/issues/' + encodeURIComponent(item.number) + '/detail').then(detail => ({
available:detail?.dependencies_available === true,
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
}));
}
function suspendTodayReadiness() {
qs('#today-readiness-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
}
function closeTodayReadiness(navigate = true) {
suspendTodayReadiness();
if (navigate) taskOverlayHistory.close();
else {
todayReadiness.cancel();
todayReadinessBlockerFocus = null;
requestAnimationFrame(() => todayReadinessTrigger?.focus());
}
}
function renderTodayReadiness(state) {
if (!state?.target) return;
const unknown = state.status === 'unknown';
qs('#today-readiness-title').textContent = unknown ? 'Blocker status unavailable' : 'This item is blocked';
qs('#today-readiness-summary').textContent = unknown
? 'Stackchain could not confirm whether this issue is ready. Retry or choose an explicit override.'
: state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.');
qs('#today-readiness-item').innerHTML = '<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; },
});
function renderCreateStartCapacity() {
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result);
return result;
}
qs('#create-issue-estimate').addEventListener('input', () => { renderCreateStartCapacity(); saveIssueCaptureDraft(); });
const queueToday = createQueueToday({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
});
const laterAndStart = createLaterAndStart({
todayWork,
todaySync,
laterWork,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
start: item => {
qs('[data-work-filter="today"]').click();
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
return todayReadiness.run('start', workSession.items(), item);
},
announce: 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 filedAssignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: item => findWorkController.claim(item),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
closeIssueSheet(false);
refreshMyWorkView();
return createAndStart.complete(claimed);
},
queue: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
const outcome = queueToday(claimed);
closeIssueSheet(false);
refreshMyWorkView();
openRoutedWork(claimed, qs('#my-work'));
return outcome;
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
closeIssueSheet(false);
refreshMyWorkView();
openRoutedWork(claimed, qs('#my-work'));
},
announce: message => {
qs('#issue-sheet-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
const batchFindWork = createBatchFindWork({
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
owner:()=>planningOwnerLogin,timeBudget:()=>{
const plan = todayWork.planning();
return {
capacity_minutes: plan.capacity_minutes,
planned_minutes: Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0),
};
},
claim: item => findWorkController.claim(item),
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
persistEstimate: (confirmed, minutes) => {
const plan = todayWork.planning();
plan.estimates[todayWork.identity(confirmed)] = minutes;
todayWork.replacePlanning(plan);
todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates);
todaySync.flush();
},
onProgress: progress => {
if (progress.status === 'full') {
qs('#find-work-status').textContent = 'Today has ' + progress.available +
' open slot' + (progress.available === 1 ? '' : 's') + '. Reduce the selection before assigning.';
} else if (progress.status === 'estimates-required') {
qs('#find-work-estimate-summary').textContent = 'Add an estimate for every issue.';
} else if (progress.status === 'over-budget') {
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(progress.over_minutes) +
' over 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();
}
function formatCalendarDueDate(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
if (!match) return '';
return new Intl.DateTimeFormat(undefined).format(
new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
);
}
let planTodayTrigger = null;
let pendingProtectToday = null;
function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set';
const absolute = Math.abs(minutes);
const hours = Math.floor(absolute / 60);
const remainder = absolute % 60;
return [hours ? hours + 'h' : '', remainder ? remainder + 'm' : ''].filter(Boolean).join(' ') || '0m';
}
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
const title = escapeHtml(item.title || 'Untitled work');
const state = planToday.snapshot();
const estimate = state.estimates?.[id] || '';
const recommendation = state.recommendations?.[id];
const estimateControl = selected ? '<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 = pendingProtectToday ? 'Protect Today' : (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;
const protectProposal = pendingProtectToday;
const selected = protectProposal?.selected || todayMyWork;
planToday.open(selected, activeMyWork, todayWork.planning(), recommendations);
pendingProtectToday = null;
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
renderPlanToday();
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');
qs('#cancel-plan-today').focus();
}
qs('#protect-today').addEventListener('click', event => {
const proposal = protectToday.propose({
agenda:agendaMyWork(activeMyWork), today:todayMyWork,
identity:item => todayWork.identity(item), limit:todayWork.limit,
});
if (!proposal.protected.length) {
qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.';
return;
}
pendingProtectToday = proposal;
openPlanToday(event.currentTarget);
});
qs('#discard-recap-replan').addEventListener('click', () => {
todayRecapView.discardReplan();
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
qs('#discard-recap-replan').hidden = true;
qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.';
renderPlanToday();
});
const detailDefer = createDetailDefer({
laterWork,
session: workSession,
continueSession: item => completeTodayItem(item, {
successMessage: 'Deferred to Later. Next Today item opened.',
failureMessage: 'Could not remove this item from Today.',
}),
close: () => workRoute.close(),
refresh: refreshMyWorkView,
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
announce: message => { qs('#my-work-action-status').textContent = message; },
formatTime: fmt,
});
const laterPickerElement = qs('#later-picker');
const laterPickerInput = qs('#later-picker-time');
let searchDefer = null;
const laterPicker = createLaterPicker({
history: window.history,
eventTarget: window,
onState: state => {
qs('#later-picker-error').textContent = state.message || '';
if (state.open) {
laterPickerInput.value = state.value || laterPickerInput.value;
qs('#later-picker-timezone').textContent = 'Times use ' +
(Intl.DateTimeFormat().resolvedOptions().timeZone || 'your device timezone') + '.';
laterPickerElement.hidden = false;
if (!laterPickerElement.open) laterPickerElement.showModal();
laterPickerInput.focus();
} else {
if (laterPickerElement.open) laterPickerElement.close();
laterPickerElement.hidden = true;
}
},
onConfirm: (item, until, context) => {
if (context === 'search') {
return () => searchDefer.run(item, until).then(outcome => {
if (outcome === 'deferred') return next();
}).catch(error => {
qs('#search-preview-status').textContent = error.message + ' Retry.';
});
}
if (context === 'detail') {
const inSession = workSession.active();
const deferred = detailDefer.deferUntil(item, until, {
closeSheet:false,
restoreFocus:false,
});
if (!deferred) return false;
return inSession ? true : () => workRoute.close();
}
const result = laterWork.defer(item, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
if (result !== 'deferred') return false;
refreshMyWorkView();
return true;
},
});
laterPicker.start();
qs('#later-picker-form').addEventListener('submit', event => {
event.preventDefault();
laterPicker.submit(laterPickerInput.value);
});
qs('#cancel-later-picker').addEventListener('click', () => laterPicker.close());
laterPickerElement.addEventListener('cancel', event => {
event.preventDefault();
laterPicker.close();
});
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
detailDefer.defer(item, button.dataset.detailDeferPreset);
});
});
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
button.addEventListener('click', () => {
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
button.closest('.detail-defer').open = false;
laterPicker.open(item, button, 'detail');
});
});
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
button.addEventListener('click', () => {
const chooser = button.closest('.detail-defer');
chooser.open = false;
chooser.querySelector('summary')?.focus();
});
});
function updatePlanningAvailability() {
document.querySelectorAll('[data-detail-defer-preset], [data-detail-defer-custom]').forEach(button => {
button.disabled = !planningOwnerLogin;
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
});
}
function setOfflineDetailControls(kind) {
const selectors = kind === 'issue' ? [
'#edit-issue-content', '#release-issue', '#load-issue-handoff',
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
] : createPullSheet.ownershipSelectors().concat([
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
]);
selectors.forEach(selector => {
const control = qs(selector);
if (control) control.disabled = true;
});
if (kind === 'issue') {
qs('#issue-planning').inert = true;
qs('#issue-handoff').inert = true;
} else {
qs('#pull-review').inert = true;
qs('#pull-ownership').inert = true;
}
}
function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#acknowledge-update-next').disabled = offline;
qs('#mute-update-next').disabled = offline;
qs('#update-ownership-action').disabled = offline;
qs('#update-ownership-start').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline;
}
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
const merge = (older, latest) => {
const byId = new Map((older || []).map(item => [item.id, item]));
(latest || []).forEach(item => {
const current = byId.get(item.id) || {};
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
byId.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
return Array.from(byId.values());
};
data.issues = merge(lastContextSnapshot.issues, data.issues);
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
}
lastContextSnapshot = data;
if (data.work_pagination) workPager.reset(data.work_pagination);
if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data);
openStagedSharedContent();
qs('#context').innerHTML = '<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();
void syncCompletedFiledReviews();
}
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
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 actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
const partitioned = laterWork.partition(actionableMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
activeMyWork = partitioned.active;
laterMyWork = partitioned.later;
const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot &&
!lastContextSnapshot?.error &&
!Object.values(workPagination).some(page => page?.has_more);
todayMyWork = todayWork.reconcile(actionableMyWork, {
pruneMissing: authoritativeMyWorkRefresh,
onPrune: retiredIds => {
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
if (queued) todaySync.flush();
return queued;
},
});
const counts = countMyWork(activeMyWork);
const agendaItems = agendaMyWork(activeMyWork);
counts.agenda = agendaItems.length;
counts.today = todayMyWork.length;
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
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;
mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh});
mobileStartDay.render();
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 === 'filed') return ['filed'];
if (selectedWorkFilter === 'issue') return ['issue'];
if (selectedWorkFilter === 'pull') return ['pull'];
if (selectedWorkFilter === 'review') return ['review'];
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
return [];
}
async function completeAgendaIssues() {
if (selectedWorkFilter !== 'agenda') return false;
if (!workPagination.issue?.has_more) return true;
if (!lastContextSnapshot) return false;
agendaChecking = true;
qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…';
renderMyWork();
const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []);
agendaChecking = false;
if (selectedWorkFilter !== 'agenda') return false;
qs('#my-work-action-status').textContent = complete ?
'All assigned deadlines checked.' :
'Agenda check paused. Retry to check older assigned deadlines.';
renderMyWork();
return complete && !workPagination.issue?.has_more;
}
function renderDrafts() {
const list = qs('#my-work-list');
const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
const deliveryCenter = draftInbox.partition(displayedDrafts);
const renderDraftCard = item => {
const index = lastDrafts.indexOf(item);
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue';
const reviewOutbox = item.outbox_kind === 'pull-review';
const closureOutbox = item.outbox_kind === 'issue-close';
const checklistConflict = item.checklist_conflict === true;
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ?
'<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' && checklistConflict ?
'<button class="draft-review-checklist" data-draft-index="' + index + '" type="button">Review changes</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</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 ? item.action : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = isOutbox ?
'<span class="pill">' + draftInbox.deliveryLabel(item) + '</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, item.details].filter(Boolean).join(' · ')) + '</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 {
await dFS.open(item.capture_id, item.ready ? button : null);
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
});
});
list.querySelectorAll('.draft-continue').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const completion = issueOutbox.pendingCompletions(activeFlushLogin)
.find(candidate => candidate.id === item?.outbox_id);
if (completion) applyOutboxResult({
confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(),
});
});
});
list.querySelectorAll('.draft-edit').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!queued) return;
button.disabled = true;
try {
const hydrated = await issueOutbox.hydrateForEdit(queued.id);
if (!hydrated) return;
editingOutboxId = hydrated.id;
issueCapture.saveDraft(hydrated);
openCreateIssueSheet();
if (hydrated.attachments || hydrated.attachment) {
createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment);
}
else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
} catch (error) {
qs('#my-work-action-status').textContent = String(error?.message ||
'The saved screenshot could not be loaded. Retry before editing this issue.');
} finally {
button.disabled = false;
}
});
});
list.querySelectorAll('.draft-send').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
});
});
list.querySelectorAll('.draft-review-checklist').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Loading the latest checklist for review…';
try {
const latest = await issueController.load({
repository:item.repository, number:item.number,
});
const preview = mergeChecklistConflict({
baseBody:queued.baseBody, localBody:queued.body, remoteBody:latest.body,
});
if (preview.conflicts.length) {
qs('#my-work-action-status').textContent = 'Checklist changes for ' +
preview.conflicts.map(conflict => conflict.label).join(', ') +
' could not be matched safely. Open the issue to compare renamed or deleted tasks.';
return;
}
if (!window.confirm('Apply ' + preview.changes.length + ' checklist change' +
(preview.changes.length === 1 ? '' : 's') + ' to the latest issue? Remote prose and other tasks will stay unchanged.')) return;
const result = await authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin);
if (result.conflicts.length) return;
const delivery = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(delivery);
qs('#my-work-action-status').textContent = delivery.confirmed?.length ?
'Checklist changes applied to the latest issue.' :
'The issue changed again. Review the checklist conflict against the new version.';
} catch (error) {
qs('#my-work-action-status').textContent = String(error?.message || 'Checklist review could not be loaded. Retry when online.');
} finally {
button.disabled = false;
}
});
});
list.querySelectorAll('.draft-authorize').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id || !activeFlushLogin) return;
button.disabled = true;
const reviewAuthorization = item.outbox_kind === 'pull-review';
qs('#my-work-action-status').textContent = reviewAuthorization ?
'Fresh authorization required for this exact review decision and head.' :
'Fresh authorization required for this exact issue.';
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
applyAuthoredOutboxResult(result);
qs('#my-work-action-status').textContent = result.confirmed?.length ?
(reviewAuthorization ? 'Review submitted and queued intent cleared.' :
'Issue closed and queued intent cleared.') :
(reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
'Issue closure was not confirmed. The queued intent is still safe.');
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
button.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', filed: 'filed 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;
qs('#filed-history-tabs').hidden = true;
return;
}
const filedItems = filedHistoryTabs.render(selectedFiledView, selectedWorkFilter === 'filed');
const queueItems = selectedWorkFilter === 'filed' ? filedItems : selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
agendaMyWork(activeMyWork) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const overdue = queueItems.filter(item => item.agenda_group === 'Overdue');
const urgentAgenda = selectedWorkFilter === 'agenda' ? queueItems.filter(item =>
item.agenda_group === 'Overdue' || item.agenda_group === 'Today') : [];
const protectPanel = qs('#protect-today-panel');
protectPanel.hidden = selectedWorkFilter !== 'agenda' || urgentAgenda.length === 0;
qs('#protect-today-status').textContent = urgentAgenda.length ?
urgentAgenda.length + ' urgent ' + (urgentAgenda.length === 1 ? 'deadline is' : 'deadlines are') + ' ready to reconcile with Today.' : '';
const replanPanel = qs('#agenda-replan');
replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0;
if (!agendaReplan?.snapshot().active) {
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
qs('#agenda-replan-controls').hidden = true;
}
const 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.' : (selectedWorkFilter === 'filed' && selectedFiledView === 'reviewed' ?
'No reviewed outcomes yet' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.'))) + '</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, controller = commentActions) {
const actions = controller?.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 paintIssueConversation(state, controller) {
const comments = state?.comments || [];
qs('#issue-comments').innerHTML = comments.length ?
comments.map(comment => renderIssueComment(comment, controller)).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 paintUpdateConversation(state, controller) {
const comments = state?.comments || [];
qs('#update-comments').innerHTML = comments.length ?
comments.map(comment => renderIssueComment(comment, controller)).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.';
const newest = qs('#update-comments .issue-comment:last-child');
updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest);
}
function paintPullConversation(state, controller) {
const comments = state?.comments || [];
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment, controller) + '</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, controller = commentActions) {
controller.wire({
root:qs(selector), getSurface:()=>commentSurface(selector),
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
});
}
const conversationActionSurfaces = {
issue: { selector:'#issue-comments', paint:paintIssueConversation },
pull: { selector:'#pull-comments', paint:paintPullConversation },
update: { selector:'#update-comments', paint:paintUpdateConversation },
};
const latestConversationStates = {};
function wireConversationActions(kind) {
if (kind === 'issue') wireCommentActions('#issue-comments');
if (kind === 'pull') wireCommentActions('#pull-comments');
if (kind === 'update') wireCommentActions('#update-comments');
}
function showConversationWithActions(kind, state) {
const surface = conversationActionSurfaces[kind];
latestConversationStates[kind] = state;
return commentActionHydrator.show({
root:qs(surface.selector), state, paint:surface.paint,
retry:qs('#retry-' + kind + '-comment-actions'),
wire:controller => wireConversationActions(kind, controller),
});
}
function renderIssueConversation(state) { void showConversationWithActions('issue', state); }
function renderPullConversation(state) { void showConversationWithActions('pull', state); }
function renderUpdateConversation(state) { void showConversationWithActions('update', state); }
function retryConversationActions(kind) {
const state = latestConversationStates[kind];
if (state) void showConversationWithActions(kind, state);
}
qs('#retry-issue-comment-actions').addEventListener('click', () => retryConversationActions('issue'));
qs('#retry-pull-comment-actions').addEventListener('click', () => retryConversationActions('pull'));
qs('#retry-update-comment-actions').addEventListener('click', () => retryConversationActions('update'));
function renderIssueLabelEditor(item, confirmedNames, labels) {
const list = qs('#issue-label-list');
const status = qs('#issue-label-status');
list.textContent = '';
const draftIds = issueController.loadLabelDraft(item);
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
);
list.innerHTML = labels.map(label =>
'<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);
}
async function loadIssuePlanning() {
const item = selectedIssue;
const detail = selectedIssueDetail;
if (!item || !detail) return;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-milestone-status').textContent = 'Loading milestones…';
try {
const planning = await planningLoader.open(selectedIssue);
if (selectedIssue !== item) return;
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
issueController.renderMilestoneEditor(item, detail.milestone, planning.milestones);
} catch (_error) {
if (selectedIssue !== item) return;
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
qs('#retry-issue-planning').hidden = false;
}
}
function renderChecklistCompletion(detail) {
const interactive = !issueController.readOnly(selectedIssue) && detail?.state === 'open' && detail?.updated_at;
const completion = issueController.checklistCompletion(qs('#issue-sheet-body'), {
interactive, today:workSession.active(), offline:selectedIssueOffline,
dismissed:dismissedChecklistBody === detail?.body,
});
const bar = qs('#checklist-completion');
bar.hidden = !completion.visible;
qs('#checklist-completion-status').textContent = completion.status;
qs('#complete-checklist-issue').textContent = completion.label;
}
function renderIssueBody(detail) {
const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
qs('#open-add-checklist-step').disabled = !interactive;
renderChecklistCompletion(detail);
}
function applyIssueContent(editing, detail, confirmed) {
const merged = issueController.mergeContent(
lastContextSnapshot, editing, detail, confirmed, buildMyWork.replaceIssueContent
);
lastContextSnapshot = merged.snapshot;
selectedIssue = merged.item;
selectedIssueDetail = merged.detail;
qs('#issue-sheet-title').textContent = confirmed.title;
renderIssueBody(selectedIssueDetail);
if (lastContextSnapshot) paintMyWork(lastContextSnapshot);
}
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
const readOnly = issueController.readOnly(item);
const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';
const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&
Array.isArray(item.assignees) && item.assignees.length > 0;
qs('#issue-sheet').classList.toggle('read-only', readOnly);
qs('#issue-sheet').classList.toggle('filed-reassignable', reassignable);
issueDetailPosition.open(workDetailIdentity('issue', item));
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
dismissedChecklistBody = null;
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…';
const completedItems = lastMyWork.filter(candidate => candidate?.is_completed && candidate?.is_filed);
const completedPosition = completedItems.findIndex(candidate =>
candidate.repository === item.repository && candidate.number === item.number
);
qs('#completed-filed-actions').hidden = !item.is_completed;
qs('#filed-claim-actions').hidden = true;
qs('#queue-filed-issue').disabled = true;
qs('#start-filed-issue').disabled = true;
qs('#completed-filed-progress').textContent = item.is_completed ?
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
qs('#issue-sheet-body').textContent = '';
checklistStepManagement.reset();
qs('#checklist-step-label').value = '';
qs('#open-add-checklist-step').disabled = true;
qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = '';
qs('#add-checklist-step-status').textContent = '';
qs('#checklist-completion').hidden = true;
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
qs('#load-older-issue-comments').hidden = true;
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-title').textContent = readOnly ? 'Add follow-up' : 'Add comment';
qs('#issue-comment-status').textContent = '';
qs('#issue-handoff').open = false;
qs('#issue-handoff-summary').textContent = reassignable ? 'Change delegate' : 'Hand off to teammate';
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#issue-handoff-recipient').disabled = true;
qs('#confirm-issue-handoff').disabled = true;
qs('#confirm-issue-handoff').textContent = reassignable ? 'Confirm change' :
(workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff');
qs('#load-issue-handoff').disabled = false;
qs('#issue-handoff-status').textContent = reassignable ?
'Load teammates to change the current delegate.' : 'Load teammates to transfer ownership.';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
qs('#save-issue-due-date').disabled = true;
qs('#clear-issue-due-date').disabled = true;
qs('#issue-due-status').textContent = 'Loading due date…';
qs('#issue-milestone').innerHTML = '<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').hidden = readOnly && !withdrawable;
qs('#close-issue').textContent = withdrawable ? 'Withdraw issue' : offlineDetail ?
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
(workSession.active() ? 'Close & next' : 'Close issue');
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#close-issue-sheet').focus();
try {
const loadedDetail = offlineDetail || await issueController.load(item);
if (selectedIssue !== item) return;
const detail = issueController.pendingTask(
item, loadedDetail, authoredOutbox.list(), confirmedOwnerLogin
);
selectedIssueDetail = detail;
renderPlanIssueDependencies(detail);
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
renderIssueBody(detail);
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
const claimableFiling = !offlineDetail && filedClaimEligible(item, detail);
qs('#filed-claim-actions').hidden = !claimableFiling;
qs('#queue-filed-issue').disabled = !claimableFiling;
qs('#start-filed-issue').disabled = !claimableFiling;
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
else renderIssueConversation(issueConversation.snapshot());
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = item.is_completed ?
'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' :
'Issue ready · ' + (detail.state || 'open');
const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';
qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';
qs('#edit-issue-content').disabled = readOnly && !revisableFiling;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
qs('#issue-due-date').disabled = false;
qs('#save-issue-due-date').disabled = false;
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + formatCalendarDueDate(detail.due_date) : 'No due date set.';
if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
if (offlineDetail) {
setOfflineDetailControls('issue');
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#retry-issue-load').focus();
}
}
function closeIssueSheet(navigate = true) {
if (navigate && issueEditHistoryActive) {
history.back();
return;
}
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueAttachmentController.clear();
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueOffline = false;
selectedIssueDetail = null;
issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
function closeIssueEditorFromHistory() {
if (!issueEditHistoryActive) return;
issueEditHistoryActive = false;
qs('#issue-edit-form').hidden = true;
if (selectedIssue) qs('#edit-issue-content').focus();
}
window.addEventListener('popstate', closeIssueEditorFromHistory);
qs('#acknowledge-completed-filed').addEventListener('click', () => {
if (!selectedIssue?.is_completed) return;
if (!completedFiledReview.acknowledge(selectedIssue)) {
qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.';
return;
}
const acknowledged = selectedIssue;
closeIssueSheet(false);
refreshMyWorkView();
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
' Filed review is complete. Acknowledgement sync pending.');
void syncCompletedFiledReviews();
if (target) {
const index = lastMyWork.indexOf(target.item);
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
} else {
if (mobileStartDay.completePhase('filed')) return;
window.location.hash = '#/my-work/filed';
qs('#my-work').focus();
}
});
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;
pullDetailPosition.open(workDetailIdentity('pull', item));
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 selectedIssueLabelNames() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => input.closest('label')?.querySelector('span')?.textContent?.trim()).filter(Boolean);
}
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
const selection = findWorkController.selection();
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
const selected = findWorkController.isSelected(item);
const detailId = 'find-work-detail-' + index;
const detail = '<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();
}
let issueCaptureBlockers = [];
function saveIssueCaptureDraft() {
if (issueCapture) issueCapture.saveDraft(currentIssueCaptureDraft(false));
}
function currentIssueCaptureDraft(trim = true) {
return withFilingEstimate(issueTemplatePicker.fields(
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, trim)
), qs('#create-issue-estimate').value);
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
let issueRepositorySearchTimer = null;
let captureBlockerSearchTimer = null;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
const selected = select.value;
const known = new Set(issueCaptureRepositories);
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository || known.has(repository)) return;
known.add(repository);
issueCaptureRepositories.push(repository);
const option = document.createElement('option');
option.value = repository;
option.textContent = repository;
select.appendChild(option);
});
if (selected) select.value = selected;
}
function updateIssueCreateActions() {
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
issueCaptureBlockers.length > 0, createAndStart.available());
}
function renderIssueCaptureBlockers(blockers) {
issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : [];
const selected = qs('#create-issue-blocker-selected');
selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => {
const item = document.createElement('li');
item.className = 'create-issue-blocker-selected';
const text = document.createElement('span');
text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove';
remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number);
remove.addEventListener('click', () => {
renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index));
saveIssueCaptureDraft();
});
item.append(text, remove);
return item;
}));
qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ?
issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') +
' selected. Blocked work will be created without starting.' : 'No blockers selected.';
updateIssueCreateActions();
}
function renderIssueCaptureBlockerResults(items) {
const results = qs('#create-issue-blocker-results');
results.replaceChildren();
(Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker =>
blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'create-issue-blocker-result';
button.setAttribute('role', 'option');
button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title;
button.addEventListener('click', () => {
renderIssueCaptureBlockers([...issueCaptureBlockers, {
repository:candidate.repository, number:Number(candidate.number), title:candidate.title,
}]);
results.hidden = true;
qs('#create-issue-blocker-search').value = '';
saveIssueCaptureDraft();
});
results.appendChild(button);
});
results.hidden = !results.childElementCount;
}
function renderIssueRepositoryResults(items) {
const results = qs('#create-issue-repository-results');
results.replaceChildren();
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'create-issue-repository-result';
button.setAttribute('role', 'option');
button.textContent = repository;
button.addEventListener('click', () => selectIssueCaptureRepository(repository));
results.appendChild(button);
});
results.hidden = !results.childElementCount;
}
function selectIssueCaptureRepository(repository) {
appendIssueRepositories([{full_name: repository}]);
qs('#create-issue-repository').value = repository;
qs('#create-issue-repository-search').value = repository;
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
issueOwnerPicker.reset(repository);
loadIssueFilingMetadata(repository);
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
qs('#create-issue-title').focus();
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
const panel = qs('#create-issue-duplicates');
const list = qs('#create-issue-duplicate-list');
const status = qs('#create-issue-duplicate-status');
const anyway = qs('#create-issue-anyway');
list.replaceChildren();
anyway.hidden = true;
if (state.status === 'idle') {
panel.hidden = true;
return;
}
panel.hidden = false;
if (state.status === 'failed') {
status.textContent = 'Could not check for existing issues. You can still create this issue.';
return;
}
if (!state.candidates.length) {
status.textContent = 'No similar open issues found in this repository.';
return;
}
status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
state.candidates.forEach(item => {
const card = document.createElement('article');
card.className = 'create-issue-duplicate-card';
const key = document.createElement('span');
key.className = 'small';
key.textContent = item.repository + ' #' + item.number;
const link = document.createElement('a');
link.textContent = item.title || 'Review existing issue';
link.href = safeSearchUrl(item.url) || '#';
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.addEventListener('click', saveIssueCaptureDraft);
card.append(key, link);
list.appendChild(card);
});
}
function scheduleIssueDuplicateCheck() {
clearTimeout(duplicateCheckTimer);
const draft = currentIssueCaptureDraft();
duplicateCheckTimer = setTimeout(async () => {
renderIssueDuplicates(await issueCapture.findDuplicates(draft));
}, 350);
}
async function loadIssueFilingMetadata(repository, selected = {}) {
return issueFilingMetadata.load(repository, selected);
}
function clearSharedLaunchUrl() {
const cleanUrl = location.pathname + location.hash;
history.replaceState(history.state || {}, '', cleanUrl);
}
function openStagedSharedContent() {
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
openCreateIssueSheet();
if (sharedLaunchState.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
sharedLaunchState = {status: 'shown'};
return;
}
clearSharedLaunchUrl();
sharedLaunchState = null;
}
async function openCreateIssueSheet(navigate = true) {
if (!issueCapture && !await ensureIssueCapture()) return;
if (navigate) {
taskOverlayHistory.open('new');
return;
}
if (!sharedImageHandled && sharedImageMarker === 'bundle') {
sharedImageHandled = true;
if (createIssueAttachmentController.state()) {
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshots.';
} else {
await sharedImageCapture.consume({
marker:sharedImageMarker,
store:unfiledAttachmentStore,
restore:value=>createIssueAttachmentController.restore(value),
restoreContent:content=>{
sharedLaunchState = issueCapture.stageSharedContent(content);
sharedLaunchHandled = true;
},
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
});
}
clearSharedLaunchUrl();
}
const captureDraft = issueCapture.loadDraft();
issueTemplatePicker.reset(captureDraft);
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
issueCaptureRepositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = '<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 || '';
qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
renderIssueCaptureBlockers(captureDraft.blockers || []);
qs('#create-issue-blocker-search').value = '';
qs('#create-issue-blocker-results').hidden = true;
loadIssueFilingMetadata(qs('#create-issue-repository').value, captureDraft);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
updateIssueCreateActions();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
if (!sharedImageHandled && sharedImageMarker) {
sharedImageHandled = true;
if (createIssueAttachmentController.state()) {
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshot.';
} else {
await sharedImageCapture.consume({
marker:sharedImageMarker,
store:unfiledAttachmentStore,
restore:value=>createIssueAttachmentController.restore(value),
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
});
}
clearSharedLaunchUrl();
}
qs('#create-issue-title').focus();
}
function setIssueFilingMode(enabled) {
applyIssueFilingMode(qs, enabled);
}
let suppressCreateDraftOnHistoryClose = false;
async function startChecklistPromotion(context) {
if (!issueCapture && !await ensureIssueCapture()) throw new Error('Issue capture is unavailable.');
checklistPromotion.start(context);
await openCreateIssueSheet();
}
const issueFilingReceipt = createIssueFilingReceipt({
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
filedButton:qs('#issue-filing-receipt-filed'),
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
fileAnotherButton:qs('#issue-filing-receipt-another'),
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
navigator,
onFileRelated:plan => {
issueCapture.saveDraft(plan);
createIssueAttachmentController.clear();
qs('#new-issue').click();
},
onFileAnother:() => qs('#new-issue').click(),
onViewFiled:issue => {
selectMobileQueue('filed');
openRoutedWork({
...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin),
});
},
});
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {
suppressCreateDraftOnHistoryClose = !preserveDraft;
taskOverlayHistory.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 => {
confirmed.work_reasons = ['created_by_me'];
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
});
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView();
const startedCompletions = new Set();
(result.completions || []).forEach(completion => {
if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return;
const created = lastMyWork.find(item => item.kind === 'issue' &&
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
if (!created) return;
const outcome = createAndStart.complete(created, completion.estimateMinutes);
if (outcome === 'started' || outcome === 'exists') {
issueOutbox.completeIntent(completion.id, activeFlushLogin);
startedCompletions.add(created.repository + '#' + created.number);
}
});
if (result.confirmed?.length) {
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
const confirmed = result.confirmed[result.confirmed.length - 1];
(result.filings || []).filter(filing => filing.relatedDraft?.checklistPromotion).forEach(filing => {
void checklistPromotion?.finish(filing.issue, filing.relatedDraft.checklistPromotion);
});
const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
if (!(confirmed.assignees || []).includes(activeFlushLogin)) {
const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository &&
candidate.issue?.number === confirmed.number);
issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft);
}
if (startCreated && !(result.completions || []).length && created) {
const outcome = createAndStart.complete(created);
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
} else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue'));
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
} else {
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
}
}
async function flushIssueOutbox() {
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
}
function applyAuthoredOutboxResult(result) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
refreshMyWorkView();
const attention = (result.remaining || []).some(item => item.status === 'attention');
qs('#my-work-action-status').textContent = result.confirmed?.length ?
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
'Message queued for sync when the connection returns.';
}
async function flushAuthoredOutbox() {
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
}
async function flushNotificationReadOutbox() {
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
const result = await notificationReadOutbox.flush(activeFlushLogin);
if (result.confirmed?.length) {
qs('#my-work-action-status').textContent = result.confirmed.length +
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
}
}
function canQueueMessage(error) {
const status = Number(error?.status || 0);
return !status || status >= 500;
}
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
};
}
function sameInlineAnchor(comment, anchor) {
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
comment.old_position === anchor.old_position;
}
function closeInlineComposer() {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-inline-composer').hidden = true;
qs('#review-inline-body').value = '';
activeInlineTarget = null;
}
function openInlineComposer(target) {
if (!draft) return;
activeInlineTarget = target;
const anchor = inlineAnchor(target);
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
qs('#review-inline-body').value = existing?.body || '';
qs('#delete-inline-comment').disabled = !existing;
qs('#review-inline-composer').hidden = false;
qs('#review-inline-body').focus();
}
async function openReviewSheet(item, trigger, cachedDetail = null) {
if (!item || !await ensurePullWorkflow(trigger)) return;
reviewDetailPosition.open(workDetailIdentity('review', item));
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 (updateReviewHandoff.active()) {
updateReviewHandoff.cancel();
return;
}
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
closeInlineComposer();
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
function closeUpdateSheet(restoreTrigger = true, navigate = true) {
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
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');
}
renderLiveDataStatus(snapshot.freshness || {});
}
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'); }
(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();
})();
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(); });
}
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>';
}
const commands = [
{ name: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Markdown', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh', run: load },
];
let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = [];
let commandSelection = -1;
let searchBatchPlanning = null;
async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}, continuation) {
const response = await fetch(filterCommands.searchUrl(query, page, scope, continuation), {
headers: { Accept:'application/json' },
signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
return payload;
}
const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork,
onState: state => {
commandSearchState = state;
renderCommands(state.query);
},
});
function currentSearchScope() {
return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value,
repository:qs('#cmd-search-repository').value.trim() };
}
function applySearchScope(scope = {kind:'all', state:'all'}) {
qs('#cmd-search-kind').value = scope.kind;
qs('#cmd-search-state').value = scope.state;
qs('#cmd-search-repository').value = scope.repository || '';
commandSearch.setScope(scope);
}
let searchPreviewDetail = null;
const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
function renderSearchPreview(state) {
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const deferButton = qs('#defer-search-result');
const queueButton = qs('#queue-search-result');
const startButton = qs('#start-search-result');
const shareButton = qs('#share-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;
deferButton.hidden = true;
deferButton.disabled = false;
queueButton.hidden = true;
startButton.hidden = true;
startButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
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 = '';
renderSearchConversation(null);
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
status.textContent = 'Loading preview…';
return;
}
if (state.status === 'error') {
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
return;
}
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
shareButton.disabled = state.status === 'sharing';
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
qs('#search-preview-meta').textContent =
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
(detail.author ? ' · by ' + detail.author : '') +
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
renderSearchPreviewReply(state, detail, searchPreview, document);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
deferButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
deferButton.textContent = detail.assigned_to_me ? 'Defer' : 'Assign & defer';
deferButton.disabled = state.status === 'claiming' || searchDefer?.pending();
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
};
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
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' } }
),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
onState: renderSearchPreview,
});
searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
);
searchDefer = createSearchDefer({
claim: detail => searchPreview.claim(detail),
accept: confirmed => acceptClaimedIssue(confirmed),
defer: (item, until) => laterWork.defer(item, until),
refresh: refreshMyWorkView,
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
formatTime: fmt,
});
const canonicalSearchPreviewUrl = () => searchPreviewUrl(
taskOverlayHistory.currentState(), currentSearchScope(), window.location
);
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,
claim,
start: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(item);
},
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
recover: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(item, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
}
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
const 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();
}
const next = searchPreview.next;
async function openPreviewWorkInMyWork(detail) {
await load();
const item = lastMyWork.find(candidate =>
candidate.kind === detail.kind && candidate.key === detail.repository + '#' + detail.number
);
if (!item) return false;
taskOverlayHistory.leave();
openRoutedWork(item, qs('#find-work'));
return true;
}
function runCommandItem(item) {
if (item.command) {
taskOverlayHistory.leave();
item.command.run();
qs('#cmd-input').value = '';
} else {
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
taskOverlayHistory.open('search-preview', {
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
});
}
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
}
function renderCommands(filter) {
const el = qs('#cmd-results');
const loadMore = qs('#cmd-load-more');
const selecting = searchBatchPlanning?.plan.snapshot().active === true;
const local = selecting ? [] : filterCommands(commands, filter).map(command => ({ command }));
const remote = commandSearchState.query === String(filter || '').trim()
? commandSearchState.items.map(result => ({ result })) : [];
commandItems = local.concat(remote);
if (commandSelection >= commandItems.length) commandSelection = -1;
let html = local.length ? '<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;
if (selecting) return searchBatchPlanning.resultHtml(result, idx);
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;
loadMore.hidden = !commandSearchState.more;
loadMore.textContent = commandSearchState.partial ? 'Retry missing results' : 'More results';
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
});
}
function openCommandPalette(navigate = true) {
if (navigate) {
taskOverlayHistory.open('search', { scope:currentSearchScope() });
return;
}
qs('#cmd-palette').classList.add('open');
mobileSearchViewport.open();
mobileSearchViewport.restoreScroll();
qs('#cmd-input').setAttribute('aria-expanded', 'true');
qs('#cmd-input').focus();
commandSelection = -1;
renderCommands(qs('#cmd-input').value);
}
const taskOverlayHistory = createTaskOverlayHistory({
history: window.history,
eventTarget: window,
onChange(kind, previous, detail) {
if (previous === 'new' && kind !== 'new') {
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
suppressCreateDraftOnHistoryClose = false;
closeCreateIssueSheet(false);
}
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
if (previous === 'search-preview' && kind !== 'search-preview') {
if (kind === 'search') closeSearchPreview(false);
else {
searchPreview.close();
mobileSearchViewport.close();
}
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') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) {
qs('#cmd-input').value = detail.query;
commandSearch.setQuery(detail.query);
}
openCommandPalette(false);
}
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
searchPreviewReturnKind = 'search';
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
}
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
},
});
taskOverlayHistory.start();
createSavedSearches.mount(
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
).load();
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
function changeSearchScope() {
const scope = currentSearchScope();
commandSelection = -1;
taskOverlayHistory.update({ scope });
commandSearch.setScope(scope);
}
qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
qs('#cmd-search-state').addEventListener('change', changeSearchScope);
let repositoryLookupController = null;
let repositoryLookupTimer = null;
async function suggestSearchRepositories() {
const input = qs('#cmd-search-repository');
const query = input.value.trim();
const status = qs('#cmd-repository-status');
if (repositoryLookupTimer !== null) clearTimeout(repositoryLookupTimer);
if (repositoryLookupController) repositoryLookupController.abort();
repositoryLookupController = null;
if (!query) {
qs('#cmd-search-repositories').innerHTML = '';
status.textContent = 'Searching all accessible repositories.';
changeSearchScope();
return;
}
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(query)) changeSearchScope();
if (query.length < 2) {
status.textContent = 'Enter at least 2 characters to find a repository.';
return;
}
status.textContent = 'Finding accessible repositories…';
repositoryLookupTimer = setTimeout(async () => {
const controller = new AbortController();
repositoryLookupController = controller;
try {
const response = await fetch('api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20', {
headers:{ Accept:'application/json' }, signal:controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Repository lookup unavailable.');
if (repositoryLookupController !== controller || input.value.trim() !== query) return;
const items = Array.isArray(payload.items) ? payload.items : [];
qs('#cmd-search-repositories').innerHTML = items.map(item =>
'<option value="' + escapeHtml(item.full_name) + '"></option>'
).join('');
status.textContent = items.length ? 'Choose an accessible repository.' : 'No accessible repositories found.';
} catch (error) {
if (error?.name !== 'AbortError') status.textContent = 'Repository lookup unavailable; global Search still works.';
} finally {
if (repositoryLookupController === controller) repositoryLookupController = null;
}
}, 200);
}
qs('#cmd-search-repository').addEventListener('input', suggestSearchRepositories);
qs('#cmd-search-repository').addEventListener('change', changeSearchScope);
qs('#cmd-search-repository').addEventListener('search', suggestSearchRepositories);
qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1;
taskOverlayHistory.update({ query:e.target.value });
renderCommands(e.target.value);
commandSearch.setQuery(e.target.value);
});
qs('#cmd-input').addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
renderCommands(event.currentTarget.value);
const selected = qs('#cmd-results .selected');
if (selected) selected.scrollIntoView({ block:'nearest' });
} else if (event.key === 'Enter' && commandSelection >= 0) {
event.preventDefault();
runCommandItem(commandItems[commandSelection]);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
e.preventDefault();
taskOverlayHistory.close();
return;
}
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
e.preventDefault();
closeSearchPreview();
return;
}
if (e.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
return;
}
if (e.key === 'Escape' && creatingIssue) {
e.preventDefault();
saveIssueCaptureDraft();
closeCreateIssueSheet();
return;
}
if (e.key === 'Escape' && selectedIssue) {
e.preventDefault();
if (!qs('#issue-edit-form').hidden) {
history.back();
return;
}
closeIssueSheet();
return;
}
if (e.key === 'Escape' && selectedPull) {
e.preventDefault();
closePullSheet();
return;
}
if (e.key === 'Escape' && selectedReview) {
e.preventDefault();
closeReviewSheet();
return;
}
if (e.key === 'Escape' && selectedUpdate) {
e.preventDefault();
closeUpdateSheet();
return;
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
if (qs('#cmd-palette').classList.contains('open')) {
taskOverlayHistory.close();
} else openCommandPalette();
}
});
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
try {
const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard);
status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.';
} catch (error) {
status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.';
}
});
});
qs('#claim-search-result').addEventListener('click', async () => {
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
try {
const claimed = searchPreviewDetail;
if (claimed.claimable) await searchPreview.claim(claimed);
const opened = await openPreviewWorkInMyWork(claimed);
if (!opened) {
qs('#search-preview-status').textContent = 'Refresh My Work to open this item.';
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#defer-search-result').addEventListener('click', event => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
laterPicker.open(detail, event.currentTarget, 'search');
});
qs('#queue-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
try {
if (todayWork.contains(detail)) {
qs('#cmd-search-action-status').textContent = 'Already in Today.';
await next();
return;
}
const outcome = await searchAssignAndStart.run(detail, {
alreadyOwned: detail.assigned_to_me,
destination: 'queue',
});
if (outcome === 'queued') await next();
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (detail?.reviewable) {
taskOverlayHistory.leave();
detail.kind = 'review';
return openRoutedWork(detail, null, { replace:true });
}
if (!detail || detail.kind !== 'issue') return;
try {
if (detail.reopenable) await searchReopenAndStart.run(detail);
else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#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 evidence = await createIssueAttachmentController.serialize();
const captureDraft = currentIssueCaptureDraft();
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
if (showDraftCapacityDialog(unfiledCaptures, qs)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
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 = unfiledSavedMessage(captureDraft);
} catch (error) {
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog({
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
});
qs('#use-shared-content').addEventListener('click', () => {
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
else issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
openCreateIssueSheet();
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
});
qs('#resume-issue-draft').addEventListener('click', () => {
issueCapture.discardSharedContent();
issueCapture.discardFollowUp();
updateFollowUp.discardSource();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
qs('#create-issue-status').textContent = 'Existing draft restored.';
qs('#create-issue-title').focus();
});
qs('#cancel-new-issue').addEventListener('click', () => {
if (checklistPromotion?.cancel()) {
closeCreateIssueSheet(true, false);
return;
}
const discardEditedDraft = Boolean(editingOutboxId);
if (editingOutboxId) {
issueCapture.clearDraft();
editingOutboxId = null;
} else saveIssueCaptureDraft();
closeCreateIssueSheet(true, !discardEditedDraft);
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
qs(selector).addEventListener('input', () => {
saveIssueCaptureDraft();
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
})
);
qs('#create-issue-repository').addEventListener('change', event => {
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
issueOwnerPicker.reset(event.target.value);
loadIssueFilingMetadata(event.target.value);
qs('#create-issue-repository-search').value = event.target.value;
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
});
qs('#create-issue-repository-search').addEventListener('input', event => {
clearTimeout(issueRepositorySearchTimer);
const query = event.target.value.trim();
const results = qs('#create-issue-repository-results');
const status = qs('#create-issue-repository-status');
if (query.length < 2) {
issueCapture.searchRepositories(query);
results.hidden = true;
status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.';
return;
}
status.textContent = 'Searching accessible repositories…';
issueRepositorySearchTimer = setTimeout(async () => {
const state = await issueCapture.searchRepositories(query);
if (state.status === 'stale') return;
if (state.status === 'failed') {
results.hidden = true;
status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.';
return;
}
renderIssueRepositoryResults(state.items);
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
}, 250);
});
qs('#create-issue-blocker-search').addEventListener('input', event => {
clearTimeout(captureBlockerSearchTimer);
const query = event.target.value.trim();
const results = qs('#create-issue-blocker-results');
const status = qs('#create-issue-blocker-status');
if (query.length < 2) {
issueCapture.searchBlockers(query);
results.hidden = true;
status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' :
(query ? 'Enter at least 2 characters to search.' : 'No blockers selected.');
return;
}
if (issueCaptureBlockers.length >= 5) {
results.hidden = true;
status.textContent = 'Five blockers selected. Remove one to choose another.';
return;
}
status.textContent = 'Searching open issues…';
captureBlockerSearchTimer = setTimeout(async () => {
const state = await issueCapture.searchBlockers(query);
if (state.status === 'stale') return;
if (state.status === 'failed') {
results.hidden = true;
status.textContent = 'Blocker search failed. Your draft is safe; retry.';
return;
}
renderIssueCaptureBlockerResults(state.items);
status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues match.';
}, 250);
});
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
saveIssueCaptureDraft();
button.disabled = true;
status.textContent = 'Loading more repositories…';
try {
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
appendIssueRepositories(payload.items);
nextIssueRepositoryPage = payload.page + 1;
moreIssueRepositoriesAvailable = payload.has_more;
button.hidden = !moreIssueRepositoriesAvailable;
status.textContent = payload.items.length ?
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
} catch (_error) {
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-anyway').addEventListener('click', () => {
const captureDraft = currentIssueCaptureDraft();
issueCapture.acknowledgeDuplicates(captureDraft);
qs('#create-issue-anyway').hidden = true;
qs('#create-issue-form').requestSubmit();
});
async function admitReviewedIssue(review) {
const durableDraft = review.draft;
const promotion = checklistPromotion?.deliveryContext();
const deliveryDraft = {
...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft),
};
if (promotion) deliveryDraft.relatedDraft.checklistPromotion = promotion;
const followUpNextRequested = review.intent === 'follow-up-and-next';
createAndStartRequested = review.intent === 'create-and-start';
if (createAndStartRequested && !createAndStart.available()) {
throw new Error('Today is full now. Go back and remove an item before creating and starting another.');
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
const followUpButton = qs('#create-follow-up-next');
button.disabled = true;
startButton.disabled = true;
followUpButton.disabled = true;
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const admission = followUpNextRequested ? (await updateFollowUp.complete({
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
issueOutbox.enqueueDurably(deliveryDraft),
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
await issueOutbox.enqueueDurably(deliveryDraft));
const queued = admission.item;
pendingIssueFilingIntent = 'create-and-assign';
const fS = dFS.current();
if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
refreshMyWorkView();
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
return;
}
editingOutboxId = null;
followUpSourceUpdate = null;
if (!followUpNextRequested) updateFollowUp.discardSource();
issueCapture.clearDraft();
createIssueAttachmentController.clear();
suppressCreateDraftOnHistoryClose = true;
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Queued for sync.';
if (navigator.onLine) applyOutboxResult(
await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested
);
createAndStartRequested = false;
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
throw error;
} finally {
button.disabled = false;
startButton.disabled = !createAndStart.available();
followUpButton.disabled = false;
}
}
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) pendingIssueFilingIntent =
event.submitter.id === 'create-and-start-issue' ? 'create-and-start' :
(event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign');
const intent = pendingIssueFilingIntent;
createAndStartRequested = intent === 'create-and-start';
const startCapacity = createAndStartRequested
? createAndStart.capacity(qs('#create-issue-estimate').value) : null;
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
qs('#create-issue-anyway').hidden = false;
qs('#create-issue-anyway').focus();
return;
}
if (createAndStartRequested && !createAndStart.available()) {
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
qs('#create-and-start-issue').focus();
return;
}
if (createAndStartRequested && !startCapacity.valid) {
qs('#create-issue-status').textContent = startCapacity.reason === 'required'
? 'Add a work estimate before creating and starting.'
: 'Use a whole-number estimate from 5 to 1440 minutes.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
if (createAndStartRequested && !startCapacity.fits) {
qs('#create-issue-status').textContent = 'This would put Today ' +
Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
qs('#create-issue-status').textContent = 'Preparing complete filing review…';
try {
const evidence = await createIssueAttachmentController.serialize();
const milestoneOption = qs('#create-issue-milestone').selectedOptions?.[0];
const durableDraft = {
...captureDraft,
labels: selectedIssueLabelNames(),
milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '',
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? {
completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes,
todayCapacity: startCapacity,
} : {}),
};
filingReview.open({draft: durableDraft, intent},
dFS.takeTrigger() || event.submitter || qs('#submit-new-issue'));
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#create-issue-title').focus();
}
});
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
function runFiledClaim(destination) {
const item = selectedIssue;
if (!item || !selectedIssueDetail || !filedClaimEligible(item, selectedIssueDetail)) return;
qs('#queue-filed-issue').disabled = true;
qs('#start-filed-issue').disabled = true;
const request = destination === 'queue' ?
filedAssignAndStart.run(item, { destination:'queue' }) : filedAssignAndStart.run(item);
request.catch(error => {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = 'Claim failed; this issue is still in Filed. ' + error.message + ' Retry.';
}).finally(() => {
if (selectedIssue === item && filedClaimEligible(item, selectedIssueDetail)) {
qs('#queue-filed-issue').disabled = false;
qs('#start-filed-issue').disabled = false;
}
});
}
qs('#queue-filed-issue').addEventListener('click', () => runFiledClaim('queue'));
qs('#start-filed-issue').addEventListener('click', () => runFiledClaim('start'));
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
issueController.bindTaskToggles({
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
confirmed:applyIssueContent,
restore:renderIssueBody,
});
const checklistStepManagement = issueController.bindTaskManagement({
container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'),
earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'),
fileRelated:qs('#file-checklist-step-related'), onFileRelated:startChecklistPromotion,
remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'),
status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
confirmed:applyIssueContent,
});
function closeAddChecklistStep() {
qs('#add-checklist-step-form').hidden = true;
qs('#add-checklist-step').value = '';
qs('#open-add-checklist-step').focus();
}
qs('#open-add-checklist-step').addEventListener('click', () => {
qs('#add-checklist-step-form').hidden = false;
qs('#add-checklist-step-status').textContent = 'Add one required step.';
qs('#add-checklist-step').focus();
});
qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep);
qs('#add-checklist-step-form').addEventListener('submit', async event => {
event.preventDefault();
const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline};
if (!state.item || !state.detail?.updated_at) return;
const submit = qs('#save-checklist-step');
submit.disabled = true;
qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…';
try {
const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(
issueController, state.item, state.detail, qs('#add-checklist-step').value
);
if (selectedIssue !== state.item) return;
applyIssueContent(state.item, state.detail, state.offline ? result.detail : result);
closeAddChecklistStep();
qs('#add-checklist-step-status').textContent = state.offline ?
'Checklist step queued. Pending sync.' : 'Checklist step added.';
} catch (error) {
if (selectedIssue === state.item) {
qs('#add-checklist-step-status').textContent = error.message;
qs('#add-checklist-step').focus();
}
} finally {
submit.disabled = false;
}
});
qs('#complete-checklist-issue').addEventListener('click', () => {
qs('#close-issue').click();
});
qs('#keep-checklist-issue-open').addEventListener('click', () => {
dismissedChecklistBody = selectedIssueDetail?.body;
if (selectedIssueDetail) renderChecklistCompletion(selectedIssueDetail);
qs('#issue-sheet-body').focus();
});
qs('#issue-planning').addEventListener('toggle', event => {
if (event.currentTarget.open) loadIssuePlanning();
});
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
qs('#edit-issue-content').addEventListener('click', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
const draft = issueController.loadEditDraft(selectedIssue) || {
title: selectedIssueDetail.title || '',
body: selectedIssueDetail.body || '',
expectedUpdatedAt: selectedIssueDetail.updated_at,
};
qs('#issue-edit-title').value = draft.title;
qs('#issue-edit-body').value = draft.body;
qs('#issue-edit-form').hidden = false;
if (!issueEditHistoryActive) {
history.pushState({ ...history.state, stackchainIssueEdit:true }, '', window.location.href);
issueEditHistoryActive = true;
}
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
qs('#issue-edit-title').focus();
});
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
qs(selector).addEventListener('input', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
issueController.saveEditDraft(selectedIssue, {
title: qs('#issue-edit-title').value,
body: qs('#issue-edit-body').value,
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
});
})
);
qs('#cancel-issue-content').addEventListener('click', () => {
if (issueEditHistoryActive) history.back();
else {
qs('#issue-edit-form').hidden = true;
qs('#edit-issue-content').focus();
}
});
qs('#issue-edit-form').addEventListener('submit', async event => {
event.preventDefault();
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
const title = qs('#issue-edit-title').value.trim();
const body = qs('#issue-edit-body').value.trim();
if (!title) {
qs('#issue-edit-status').textContent = 'Add a title before saving.';
qs('#issue-edit-title').focus();
return;
}
const editing = selectedIssue;
const savedDraft = issueController.loadEditDraft(editing);
const draft = {
title,
body,
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
};
const button = qs('#save-issue-content');
button.disabled = true;
qs('#issue-edit-status').textContent = 'Saving issue…';
try {
const confirmed = await issueController.updateContent(editing, draft);
applyIssueContent(editing, selectedIssueDetail, confirmed);
if (issueEditHistoryActive) history.back();
else qs('#issue-edit-form').hidden = true;
qs('#issue-sheet-status').textContent = 'Issue saved.';
qs('#edit-issue-content').focus();
} catch (error) {
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#issue-edit-title').focus();
} finally {
button.disabled = false;
}
});
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
qs('#load-older-issue-comments').addEventListener('click', async () => {
if (!issueConversation) return;
const button = qs('#load-older-issue-comments');
const panel = qs('#issue-sheet .issue-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#issue-conversation-status').textContent = 'Loading older messages…';
try {
if (issueController.readOnly(selectedIssue)) {
paintIssueConversation(await issueConversation.loadOlder(), null);
} else {
renderIssueConversation(await issueConversation.loadOlder());
}
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#save-issue-labels').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const button = qs('#save-issue-labels');
button.disabled = true;
qs('#issue-label-status').textContent = 'Saving labels…';
try {
const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds());
lastContextSnapshot = buildMyWork.replaceIssueLabels(
lastContextSnapshot, editing.repository, editing.number, confirmed.labels
);
selectedIssue = { ...editing, labels: confirmed.labels };
qs('#issue-labels').innerHTML = confirmed.labels.map(label =>
'<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) {
const conversation = issueConversation.append(comment);
if (issueController.readOnly(selectedIssue)) paintIssueConversation(conversation, null);
else renderIssueConversation(conversation);
}
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);
const currentAssignees = new Set(selectedIssue.is_filed ? (selectedIssue.assignees || []) : []);
const availableCandidates = candidates.filter(candidate => !currentAssignees.has(candidate.login));
select.textContent = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = availableCandidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
availableCandidates.forEach(candidate => {
const option = document.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
select.disabled = !availableCandidates.length;
qs('#confirm-issue-handoff').disabled = true;
qs('#issue-handoff-status').textContent = availableCandidates.length ?
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
if (availableCandidates.length) select.focus();
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
button.disabled = false;
button.focus();
}
});
qs('#issue-handoff-recipient').addEventListener('change', event => {
qs('#confirm-issue-handoff').disabled = !event.target.value;
});
qs('#confirm-issue-handoff').addEventListener('click', async () => {
const recipient = qs('#issue-handoff-recipient').value;
if (!selectedIssue || !recipient) return;
const changingDelegate = selectedIssue.is_filed && !selectedIssue.is_completed &&
Array.isArray(selectedIssue.assignees) && selectedIssue.assignees.length > 0;
const previousDelegates = changingDelegate ? selectedIssue.assignees.join(', @') : '';
const confirmation = changingDelegate ?
'Change delegate from @' + previousDelegates + ' to @' + recipient + ' for ' + selectedIssue.key + '?' :
'Hand off ' + selectedIssue.key + ' to @' + recipient + '?';
if (!window.confirm(confirmation)) return;
const handingOff = selectedIssue;
const continuingSession = workSession.checkpointed(handingOff);
const button = qs('#confirm-issue-handoff');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
try {
if (changingDelegate) {
const confirmed = await issueController.reassign(selectedIssue, recipient);
Object.assign(handingOff, { assignees:confirmed.assignees, updated_at:confirmed.updated_at || handingOff.updated_at });
selectedIssue = handingOff;
if (selectedIssueDetail) selectedIssueDetail.assignees = confirmed.assignees;
qs('#issue-assignees').textContent = 'Assigned to ' + recipient;
qs('#issue-handoff').open = false;
qs('#issue-handoff-status').textContent = 'Delegate changed to @' + recipient + '.';
refreshMyWorkView({ reconcileSession:false });
return;
}
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, handingOff.repository, handingOff.number
);
closeIssueSheet();
if (continuingSession) {
const transitionResult = await completeOwnershipExitToday(handingOff);
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Choose the next ready Today item.';
} else if (transitionResult === null) {
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + ', but Today still needs completion.';
}
} else {
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
}
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue) return;
const withdrawing = selectedIssue.is_filed && !selectedIssue.is_assigned;
const confirmation = withdrawing ?
'Withdraw ' + selectedIssue.key + '? This closes the delegated request.' :
'Close ' + selectedIssue.key + '?';
if (!window.confirm(confirmation)) return;
const closing = selectedIssue;
const button = qs('#close-issue');
button.disabled = true;
if (selectedIssueOffline) {
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
try {
const outcome = await closeOfflineIssue(closing);
closeIssueSheet();
refreshMyWorkView({ reconcileSession:false });
if (!outcome.advanced) {
qs('#my-work-action-status').textContent = 'Issue 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 {
const result = await issueController.close(selectedIssue);
closeIssueSheet();
if (withdrawing) Object.assign(closing, {
state:'closed',is_completed:true,updated_at:result.updated_at || closing.updated_at,
});
else lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number));
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
} else if (transitionResult === 'gated') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = closing.key + (withdrawing ? ' withdrawn.' : ' closed.');
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#pull-review').addEventListener('toggle', event => {
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#refresh-pull-checks').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const button = qs('#refresh-pull-checks');
button.disabled = true;
qs('#pull-checks-summary').textContent = 'Refreshing…';
try {
const status = await pullController.loadChecks(item);
if (selectedPull !== item) return;
renderPullCheckStatus(status);
} catch (_error) {
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-pull-file').addEventListener('click', () => {
createPullSheet.focusNextUnreviewed(document, selectedPullDetail, pullReviewState, pullController);
});
qs('#load-older-pull-comments').addEventListener('click', async () => {
if (!pullConversation) return;
const button = qs('#load-older-pull-comments');
const panel = qs('#pull-sheet .pull-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#pull-conversation-status').textContent = 'Loading older messages…';
try {
renderPullConversation(await pullConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
qs('#send-pull-comment').addEventListener('click', async () => {
if (!selectedPull) return;
const item = selectedPull;
const body = qs('#pull-comment').value.trim();
if (!body && !pullAttachmentController.state()) {
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
qs('#pull-comment').focus();
return;
}
const button = qs('#send-pull-comment');
button.disabled = true;
pullAttachmentController.setBusy(true);
qs('#pull-comment-status').textContent = pullAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' +
item.repository + '#' + item.number + ':operation');
let preparedBody;
try {
if (pullAttachmentController.state()) {
const admission = await queuePullScreenshotComment(item, body, operationId, false, true);
refreshMyWorkView();
pullController.saveDraft(item, '');
if (selectedPull === item) {
if (admission.delivered && pullConversation) {
renderPullConversation(pullConversation.append(admission.delivered));
}
qs('#pull-comment').value = '';
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();
}
});
const updateDecisionActions = createUpdateDecisionActions({
transaction:updateDecision, triage:updateTriage, close:()=>closeUpdateSheet(true),
reader:notificationReader, items:()=>lastMyWork, undo:notificationUndo,
keepControl:qs('#keep-update-unread'), readControl:qs('#mark-update-read-next'),
});
const keepUpdateUnread = () => updateDecisionActions.keepUnread();
const markUpdateRead = () => updateDecisionActions.markRead();
qs('#keep-update-unread').addEventListener('click', keepUpdateUnread);
createUpdateTriageGesture({
surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail,
keepUnread: keepUpdateUnread, markRead: markUpdateRead,
});
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
});
qs('#retry-update-conversation').addEventListener('click', () => {
notificationReader.retryConversation();
});
qs('#load-older-update-comments').addEventListener('click', async () => {
const button = qs('#load-older-update-comments');
const panel = qs('#update-sheet .update-sheet-panel');
const previousHeight = panel.scrollHeight;
const previousTop = panel.scrollTop;
button.disabled = true;
await notificationReader.loadOlder();
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
button.disabled = false;
});
qs('#update-reply').addEventListener('input', event => {
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
});
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
updateReplyAttachmentController.setBusy(true);
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
qs('#send-update-reply').disabled = false;
updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
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);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
} finally {
button.disabled = false;
sendButton.disabled = false;
updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', markUpdateRead);
qs('#acknowledge-update-next').addEventListener('click', async () => {
const button = qs('#acknowledge-update-next');
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) {
notificationUndo.offer(result.item, result.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#mute-update-next').addEventListener('click', async () => {
if (!selectedUpdate || offlineWorkMode) return;
const button = qs('#mute-update-next');
const item = selectedUpdate;
button.disabled = true;
qs('#update-sheet-status').textContent = 'Muting future updates…';
try {
await muteNotification(item.notification_id);
const result = await notificationReader.acceptReadAndNext(lastMyWork, item);
if (result && updateTriage.active()) updateTriage.acceptCompleted();
} catch (error) {
qs('#update-sheet-status').textContent = error.muted ?
'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message;
if (error.muted) {
button.hidden = true;
qs('#mark-update-read-next').focus();
}
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
const restored = await notificationUndo.run();
button.disabled = restored;
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
});
qs('#refresh-review-checks').addEventListener('click', async () => {
if (!selectedReview || offlineReview) return;
const item = selectedReview;
const button = qs('#refresh-review-checks');
button.disabled = true;
qs('#review-checks-summary').textContent = 'Refreshing…';
try {
const detail = await reviewController.loadChecks(item);
if (selectedReview !== item) return;
if (selectedReviewHead && detail.head_sha !== selectedReviewHead) {
qs('#review-sheet-status').textContent = 'New commits detected. Reload the review before submitting feedback.';
qs('#submit-review').disabled = true;
}
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail);
} catch (error) {
if (selectedReview === item) qs('#review-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-review').addEventListener('click', () => {
if (progress) openNextUnreviewed(progress.snapshot());
});
qs('#review-wrap-lines').addEventListener('click', () => {
const next = !wrapPreference.snapshot().wrapped;
applyReviewWrap(wrapPreference.setWrapped(next));
});
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#save-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
const body = qs('#review-inline-body').value.trim();
if (!body) {
qs('#review-inline-body').focus();
return;
}
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
activeInlineTarget.classList.add('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#delete-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
activeInlineTarget.classList.remove('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#cancel-inline-comment').addEventListener('click', () => {
const target = activeInlineTarget;
closeInlineComposer();
target?.focus();
});
qs('#submit-review').addEventListener('click', async () => {
if (!draft || !selectedReview || !selectedReviewHead) return;
const snapshot = draft.snapshot();
const labels = { approve: 'Approve', request_changes: 'Request changes' };
if (labels[snapshot.decision] && !window.confirm(
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
)) return;
const button = qs('#submit-review');
button.disabled = true;
if (updateReviewHandoff.active() && reviewHandoffSubmitted) {
try {
await updateReviewHandoff.complete();
} catch (error) {
qs('#review-submit-status').textContent = 'Review submitted, but the update is still unread. ' +
error.message + ' Retry to continue.';
button.disabled = false;
button.focus();
}
return;
}
if (offlineReview) {
const queuedTodayReview = reviewingActiveTodayItem();
qs('#review-submit-status').textContent = 'Queueing review safely…';
try {
await authoredOutbox.enqueueDurably({
kind: 'pull-review',
repository: selectedReview.repository,
number: selectedReview.number,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
decision: snapshot.decision,
expectedHeadSha: selectedReviewHead,
comments: snapshot.comments,
draftKey: draft.storageKey,
progressKey: progress?.storageKey || '',
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
});
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
if (queuedTodayReview) {
const advanced = completeTodayItem(selectedReview, {
successMessage: 'Review queued. Next Today item opened.',
failureMessage: 'Review queued, but Today still needs completion.',
});
if (!advanced) {
qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.';
}
} else {
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
button.disabled = false;
button.focus();
}
return;
}
qs('#review-submit-status').textContent = 'Submitting review…';
try {
const item = selectedReview;
const progressSnapshot = progress?.snapshot() || { reviewed: [] };
const result = await reviewController.submit(selectedReview, {
decision: snapshot.decision,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
expected_head_sha: selectedReviewHead,
comments: snapshot.comments,
});
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
const canContinueToMerge = createReviewController.prepareMergeContinuation({
storage: localStorage,
item,
headSha: selectedReviewHead,
reviewed: progressSnapshot.reviewed,
decision: snapshot.decision,
});
draft.clear();
progress?.clear();
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
if (progress) showReviewProgress(progress.snapshot());
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
if (canContinueToMerge) {
qs('#continue-review-to-merge').hidden = false;
qs('#continue-review-to-merge').focus();
} else {
await load();
if (workSession.active()) await runTodayTransition('complete');
else button.focus();
}
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
button.disabled = false;
button.focus();
}
});
qs('#continue-review-to-merge').addEventListener('click', () => {
const item = selectedReview;
if (!item) return;
workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true });
});
qs('#copy-review-feedback').addEventListener('click', async () => {
if (!draft || !selectedReview || reviewHandoffPending) return;
reviewHandoffPending = true;
qs('#copy-review-feedback').disabled = true;
const fallback = qs('#review-copy-fallback');
const directLink = qs('#review-handoff-link');
fallback.hidden = true;
directLink.hidden = true;
let handoffWindow = null;
try {
const result = await createReviewController.copyAndContinue({
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
url: selectedReview.url,
copy: text => navigator.clipboard.writeText(text),
open: () => {
handoffWindow = window.open('about:blank', '_blank');
if (handoffWindow) handoffWindow.opener = null;
return handoffWindow;
},
fallback: text => {
fallback.value = text;
fallback.hidden = false;
fallback.focus();
fallback.select();
},
});
if (result.opened) {
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
} else {
directLink.hidden = false;
qs('#review-handoff-status').textContent = result.copied ?
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
}
} finally {
reviewHandoffPending = false;
qs('#copy-review-feedback').disabled = false;
}
});
const contextPoller = createContextPoller({
fetchContext: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot,
onError: async error => {
await handleContextError(error);
setEventStreamStatus('Update failed · showing last activity');
},
isHidden: () => document.hidden,
intervalMs: 8000,
});
function load() { return contextPoller.refresh({ force: true }); }
const liveDataStatusSheet = qs('#live-data-status-sheet');
const liveDataStatusTrigger = qs('#open-live-data-status');
let latestLiveFreshness = {};
function feedAge(feed) {
return feed.ageSeconds === null ? 'Snapshot age unavailable' :
(feed.ageSeconds < 60 ? feed.ageSeconds + 's old' : Math.floor(feed.ageSeconds / 60) + 'm old');
}
function renderLiveDataStatus(freshness = latestLiveFreshness) {
latestLiveFreshness = freshness;
const description = liveDataStatus.describe(freshness);
setStatus(description.summary);
qs('#live-data-status-feeds').innerHTML = description.feeds.map(feed =>
'<div class="live-data-status-feed"><strong>' + escapeHtml(feed.label) + '</strong><span>' +
escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) :
feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '</span></div>'
).join('');
const pollState = contextPoller.getState();
const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ?
Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null);
qs('#live-data-status-retry').textContent = retrySeconds ?
'Next automatic retry in ' + retrySeconds + 's.' :
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
}
function closeLiveDataStatus() {
liveDataStatusSheet.hidden = true;
liveDataStatusTrigger.setAttribute('aria-expanded', 'false');
liveDataStatusTrigger.focus();
}
liveDataStatusTrigger.addEventListener('click', () => {
renderLiveDataStatus();
liveDataStatusSheet.hidden = false;
liveDataStatusTrigger.setAttribute('aria-expanded', 'true');
qs('#close-live-data-status').focus();
});
qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus);
liveDataStatusSheet.addEventListener('click', event => {
if (event.target === liveDataStatusSheet) closeLiveDataStatus();
});
liveDataStatusSheet.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); }
});
const liveDataRefresh = liveDataStatus.createRefreshController({
button: qs('#refresh-live-data'),
output: qs('#live-data-status-result'),
refresh: () => contextPoller.refresh({ force: true }),
onState: state => { if (state === 'refreshing') setStatus('Refreshing live data'); },
});
qs('#refresh-live-data').addEventListener('click', liveDataRefresh.run);
const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status');
const retryOfflineStorage = qs('#retry-offline-storage');
const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status');
async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled();
keepWorkOffline.disabled = !offlineStorageReady;
retryOfflineStorage.hidden = offlineStorageReady;
const saved = await offlineWorkStore.load();
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
}
async function updateDeliveryReceiptControls(message) {
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
if (!supported) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
return;
}
if (!confirmedOwnerLogin) {
deliveryReceipts.checked = false;
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
return;
}
deliveryReceipts.checked = Notification.permission === 'granted' &&
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
}
function setOfflineWorkMode(value) {
offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
}
async function hydrateOfflineWork(mode = 'offline') {
const saved = await offlineWorkStore.load();
if (!saved) return false;
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
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() {
setStatus('Reconnecting…');
contextPoller.refresh({ force: true }).then(snapshot => {
if (!snapshot) {
offlineStatus.hidden = false;
setOfflineWorkMode(true);
return;
}
offlineStatus.hidden = true;
setOfflineWorkMode(false);
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 = workSession.items();
if (!sessionItems.length) {
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
if (selectedWorkFilter === 'today') runTodayTransition('start');
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
else workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);
document.querySelectorAll('[data-work-session-next]').forEach(button =>
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
);
document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button =>
button.addEventListener('click', () => timerView.toggle())
);
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
button.addEventListener('click', () =>
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
)
);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#load-more-work').addEventListener('click', async () => {
if (selectedWorkFilter === 'agenda') {
await completeAgendaIssues();
return;
}
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
if (!stream || !lastContextSnapshot) return;
const button = qs('#load-more-work');
button.disabled = true;
const existing = stream === 'issue' || stream === 'filed' ?
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
try {
const loaded = await workPager.loadMore(stream, existing);
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
else button.focus();
} finally {
button.disabled = false;
}
});
qs('#select-work').addEventListener('click', () => {
if (notificationSelection.snapshot().active) notificationSelection.cancel();
workSelection.start();
document.querySelector('[data-select-work-id]')?.focus();
});
qs('#cancel-work-selection').addEventListener('click', () => {
workSelection.cancel();
qs('#select-work').focus();
});
qs('#batch-add-today').addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const result = todayWork.addMany(selectedItems);
if (result.status === 'added') {
result.ids.forEach(id => todaySync.enqueue('add', id));
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
workSelection.cancel();
} else {
qs('#my-work-action-status').textContent = result.status === 'full' ?
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
(result.status === 'exists' ? 'Every selected item is already in Today.' :
'Could not save Today on this device. Nothing was added; selection unchanged.');
}
refreshMyWorkView();
});
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const until = laterWork.presetUntil(button.dataset.batchDefer);
const result = laterWork.deferMany(selectedItems, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') workSelection.cancel();
refreshMyWorkView();
}));
qs('#select-updates').addEventListener('click', () => {
notificationSelection.start();
qs('#update-selection-status').textContent = '0 of 50 selected';
document.querySelector('[data-select-notification-id]')?.focus();
});
qs('#cancel-update-selection').addEventListener('click', () => {
notificationSelection.cancel();
qs('#select-updates').focus();
});
document.querySelectorAll('[data-defer-selected]').forEach(button => button.addEventListener('click', () => {
const selection = notificationSelection.snapshot();
const ids = new Set(selection.ids);
const selectedUpdates = lastMyWork.filter(item => item.has_update && ids.has(item.notification_id));
const until = laterWork.presetUntil(button.dataset.deferSelected);
const result = laterWork.deferMany(selectedUpdates, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selection.count + ' updates deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') notificationSelection.cancel();
refreshMyWorkView();
}));
qs('#bulk-mark-read').addEventListener('click', async () => {
const selection = notificationSelection.snapshot();
if (!selection.ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
renderMyWork();
return;
}
bulkConfirmationPending = false;
bulkMarkPending = true;
renderMyWork();
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
if (result) {
const marked = new Set(result.marked);
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
notificationSelection.retain(result.failed);
if (!result.failed.length) notificationSelection.cancel();
}
bulkMarkPending = false;
renderMyWork();
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
});
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
selectWorkQueue(button.dataset.workFilter);
});
});
function updateQueueFinder(matches, loaded, incomplete) {
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
qs('#queue-find-label').textContent = queueLabel;
qs('#clear-queue-find').hidden = !queueFindQuery;
qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
qs('#queue-find-status').textContent = queueFindQuery ?
(matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
(incomplete ? ' among loaded work.' : '.')) : '';
}
qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
qs('#queue-find-input').addEventListener('input', event => {
queueFindQuery = event.target.value;
renderMyWork();
});
qs('#clear-queue-find').addEventListener('click', () => {
queueFindQuery = '';
qs('#queue-find-input').value = '';
renderMyWork();
qs('#queue-find-input').focus();
});
qs('#search-older-work').addEventListener('click', () => {
const notificationButton = qs('#load-more-notifications');
const workButton = qs('#load-more-work');
const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
if (!target || target.hidden || target.disabled) return;
qs('#queue-find-status').textContent = 'Searching older ' +
(selectedWorkFilter === 'update' ? 'updates…' : 'work…');
target.click();
});
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
const button = qs('[data-work-filter="' + filter + '"]');
if (!button) return false;
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
selectedWorkFilter = filter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
if (workSelection.snapshot().active) workSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {}
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item === button))
);
const selectedCount = button.querySelector('[data-work-count]').textContent;
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
renderMyWork();
updateWorkSessionActions();
updateWorkPaginationControls();
if (selectedWorkFilter === 'agenda') completeAgendaIssues();
if (!preserveRoute) workRoute.queue(filter);
return true;
}
async function openWorkQueueRoute(filter, action = null) {
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
if (action !== 'protect-today') return;
await completeAgendaIssues();
if (workPagination.issue?.has_more) {
qs('#my-work-action-status').textContent =
'Protect Today needs all assigned deadlines. Retry when connected.';
return;
}
const proposal = protectToday.propose({
agenda:agendaMyWork(activeMyWork), today:todayMyWork,
identity:item => todayWork.identity(item), limit:todayWork.limit,
});
if (!proposal.protected.length) {
qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.';
return;
}
pendingProtectToday = proposal;
openPlanToday(qs('#protect-today'));
}
qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value;
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
catch (e) {}
renderMyWork();
if (workSession.active()) workSession.reconcile();
});
let pushController = null;
for (const selector of [qs('#push-deadline-hour'), qs('#device-setup-deadline-hour')]) {
for (let hour = 0; hour < 24; hour += 1) {
const option = document.createElement('option');
option.value = String(hour);
option.textContent = `${String(hour).padStart(2, '0')}:00`;
selector.appendChild(option);
}
selector.value = '9';
}
qs('#push-deadline-hour').addEventListener('change', event => {
qs('#device-setup-deadline-hour').value = event.target.value;
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
});
qs('#device-setup-deadline-hour').addEventListener('change', event => {
qs('#push-deadline-hour').value = event.target.value;
});
qs('#push-deadline-days').addEventListener('change', event => {
qs('#device-setup-deadline-days').value = event.target.value;
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
});
qs('#device-setup-deadline-days').addEventListener('change', event => {
qs('#push-deadline-days').value = event.target.value;
});
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'),
deadlineHour:qs('#push-deadline-hour'),
deadlineDays:qs('#push-deadline-days'),
notification:window.Notification,
serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson,
});
await controller.init();
pushController = controller;
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
return controller;
}).catch(error => {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
return null;
});
} else {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
}
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();
},
deadlineReadiness:() => pushController?.deadlineReadiness()
|| {state:'unavailable', detail:'Deadline reminders are unavailable.'},
enableDeadline:async () => {
const controller = await pushControllerReady;
if (!controller) return;
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
await controller.enableDeadline();
},
});
await deviceSetup.start();
return deviceSetup;
}
qs('#open-device-setup').addEventListener('click', async event => {
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
});
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
await load();
await appShortcut.run();
contextPoller.start();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) deviceSetup?.render();
});
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();