Compare commits

...
Author SHA1 Message Date
timmy 679639394f feat: compress dashboard shell over HTTP (#417)
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 18:33:40 +00:00
rockachopa 3b374cbb50 Merge pull request 'Queue available work in Today without interrupting the current session' (#416)
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #415
2026-08-09 18:00:05 +00:00
timmy ad5e50eb48 feat: queue available work in Today (#415)
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:57:48 +00:00
rockachopa 4b8d0c4504 Merge pull request 'Assign and start work from global search' (#414) from timmy/413-search-assign-and-start into main
CI / lint (push) Successful in 45s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 17:08:02 +00:00
timmy 6e3b6804c1 feat: assign and start work from search (#413)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:06:16 +00:00
rockachopa c31129f1cc Merge pull request 'Assign available work and start it from Find' (#412) from timmy/411-assign-and-start into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 16:22:55 +00:00
timmy 4afe4dd3b9 feat: assign and start available work (#411)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 16:21:20 +00:00
rockachopa 985472eb06 Merge pull request 'Continue Today from the mobile Work dock' (#410) from timmy/409-continue-today-mobile-dock into main
CI / lint (push) Successful in 47s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 15:44:22 +00:00
timmy 8b36455c21 feat: continue Today from mobile Work dock (#409)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 15:42:42 +00:00
rockachopa 3ba1fe76f0 Merge pull request 'Advance Today after merging a planned pull request' (#408) from timmy/407-merge-pr-and-advance-today into main
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Advance Today after merging a planned pull request (#408)

Closes #407
2026-08-09 14:56:39 +00:00
timmy 321474e8cf feat: advance Today after pull request merge (#407)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:55:03 +00:00
rockachopa 166d14dd35 Merge pull request 'Preview work before adding it to Today' (#406) from timmy/405-plan-today-preview into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 14:24:29 +00:00
timmy 7a62f7ead7 feat: preview work before planning (#405)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:22:37 +00:00
rockachopa 461f90c8ff Merge pull request 'Support issue capture across all accessible repositories' (#404) from timmy/403-issue-capture-repository-pagination into main
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 13:40:58 +00:00
timmy 9ea590a65c feat: paginate issue capture repositories (#403)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 13:39:27 +00:00
rockachopa 33a6d1bb6d Merge pull request 'Make Plan Today browser-back safe on mobile' (#402) from timmy/401-plan-today-back into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 4s
2026-08-09 12:40:56 +00:00
timmy b9ae9c6c83 feat: make Plan Today browser-back safe (#401)
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:39:19 +00:00
rockachopa 45ceedb679 Merge pull request 'Expire offline private work with operator sessions' (#400) from timmy/399-offline-session-lease into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 12:06:15 +00:00
timmy 2e2a7821ae feat: expire offline work with sessions (#399)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:03:55 +00:00
rockachopa 33427d2407 Merge pull request 'Recover foreground dashboard actions from stalled requests' (#398) from timmy/397-foreground-request-deadlines into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 11:39:22 +00:00
timmy f0b0b703ff feat: bound foreground API requests (#397)
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:37:57 +00:00
34 changed files with 2017 additions and 101 deletions

No files matched your search

+6 -1
View File
@@ -61,7 +61,12 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
+49
View File
@@ -0,0 +1,49 @@
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
let request = null;
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
if (request) return request;
if (!available()) {
announce('Today is full—remove an item before assigning this issue.');
return Promise.resolve('full');
}
request = Promise.resolve()
.then(() => alreadyOwned ? item : claim(item))
.then(confirmed => {
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
if (outcome === 'queued') {
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
'Assigned and queued in Today. Keep finding work when ready.');
return outcome;
}
if (outcome === 'started') {
announce(alreadyOwned ? 'Added to Today and ready to work.' :
'Assigned, added to Today, and ready to work.');
return outcome;
}
if (destination === 'queue') {
if (outcome === 'sync-unavailable') {
announce(alreadyOwned ?
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
} else {
announce(alreadyOwned ?
'Today could not be queued. The issue is open so you can recover.' :
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
}
} else {
announce(alreadyOwned ?
'Today could not start. The issue is open so you can recover.' :
'Assigned to you, but Today could not start. The issue is open so you can recover.');
}
recover(confirmed);
return 'recovery';
})
.finally(() => { request = null; });
return request;
}
return { run };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
+17 -1
View File
@@ -26,6 +26,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
let duplicateRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@@ -143,6 +144,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
.then(payload => ({
items: Array.isArray(payload?.items) ? payload.items : [],
page: Number(payload?.page) || safePage,
total: Math.max(0, Number(payload?.total) || 0),
has_more: payload?.has_more === true,
}))
.finally(() => repositoryPageRequests.delete(safePage));
repositoryPageRequests.set(safePage, request);
return request;
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@@ -214,7 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
+9
View File
@@ -94,6 +94,10 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
.plan-preview-actions[hidden] { display:none; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
@@ -259,6 +263,8 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -269,6 +275,7 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
@@ -289,6 +296,7 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
@@ -364,6 +372,7 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-panel { width:100%; border-left:0; padding:14px; }
.plan-today-item { grid-template-columns:1fr; }
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
+305 -29
View File
@@ -43,20 +43,30 @@
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
);
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
function openMobileWork() {
function openMobileWorkFallback() {
const counts = countMyWork(activeMyWork);
const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');
const filter = counts.attention ? 'attention' : 'all';
qs('[data-work-filter="' + filter + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
openFallback: openMobileWorkFallback,
});
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
attentionBadge: qs('#mobile-attention-count'),
overlays: mobileTaskOverlays,
actions: {
work: openMobileWork,
work: () => mobileWorkEntry.open(),
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
@@ -678,6 +688,7 @@
qs('#start-work-session').hidden = active;
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
}
const workSession = createWorkSession({
@@ -709,6 +720,31 @@
},
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
function continueTodaySession() {
selectTodayWork();
if (!workSession.reopen() && !workSession.reconcile()) {
qs('#my-work-action-status').textContent = 'Current Today item is no longer available.';
updateWorkSessionActions();
}
}
function resumeTodaySession() {
selectTodayWork();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
}
function startTodaySession() {
selectTodayWork();
workSession.start();
}
const createAndStart = createCreateAndStart({
todayWork,
todaySync,
@@ -721,6 +757,49 @@
announce: message => { qs('#my-work-action-status').textContent = message; },
});
const queueToday = createQueueToday({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
});
function acceptClaimedIssue(confirmed) {
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
candidate.repository !== confirmed.repository || candidate.number !== confirmed.number
));
lastMyWork = buildMyWork(lastContextSnapshot);
return lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
}
const assignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: item => findWorkController.claim(item),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
queue: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
return queueToday(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#find-work'));
},
announce: message => {
qs('#find-work-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
let planTodayTrigger = null;
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
@@ -728,7 +807,7 @@
const title = escapeHtml(item.title || 'Untitled work');
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 === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
'<button type="button" data-plan-add="' + escAttr(id) + '">Add</button>';
'<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></div>' + controls + '</article>';
}
@@ -747,6 +826,15 @@
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 = '';
@@ -759,7 +847,11 @@
}));
}
function closePlanToday() {
function closePlanToday(navigate = true) {
if (navigate) {
taskOverlayHistory.close();
return;
}
planToday.cancel();
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
@@ -788,12 +880,68 @@
},
});
function openPlanToday(trigger) {
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 (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('#plan-today-sheet').hidden = false;
renderPlanToday();
requestAnimationFrame(() => trigger?.focus());
},
});
let addPlanPreviewOnReturn = false;
qs('#back-to-plan').addEventListener('click', () => {
addPlanPreviewOnReturn = false;
taskOverlayHistory.close();
});
qs('#add-plan-preview').addEventListener('click', () => {
addPlanPreviewOnReturn = true;
taskOverlayHistory.close();
});
function openPlanToday(trigger, navigate = true) {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
planTodayTrigger = trigger;
if (trigger) planTodayTrigger = trigger;
if (navigate) {
taskOverlayHistory.open('plan-today');
return;
}
planToday.open(todayMyWork, activeMyWork);
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
@@ -1036,7 +1184,7 @@
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
mobileTaskDock.updateAttention(counts.attention);
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
' (' + (counts[selectedWorkFilter] || 0) + ')';
@@ -1702,6 +1850,7 @@
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
qs('#close-pull-sheet').focus();
@@ -1763,7 +1912,10 @@
'<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 + '<button type="button" data-claim-index="' + index + '">Assign to me</button></article>';
detail + '<div class="find-work-claim-actions"><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-preview-index]').forEach(button => {
button.addEventListener('click', () => {
@@ -1783,12 +1935,7 @@
button.disabled = true;
try {
const confirmed = await findWorkController.claim(item);
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
lastMyWork = buildMyWork(lastContextSnapshot);
const claimed = lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
@@ -1800,6 +1947,41 @@
}
});
});
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) {
@@ -1860,6 +2042,27 @@
};
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
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;
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
@@ -1982,13 +2185,18 @@
return;
}
const captureDraft = issueCapture.loadDraft();
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
repositories.unshift(captureDraft.repository);
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 = repositories.map(repository =>
qs('#create-issue-repository').innerHTML = 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;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
@@ -1996,9 +2204,9 @@
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !issueCaptureRepositories.length;
qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
qs('#create-issue-title').focus();
@@ -2467,6 +2675,7 @@
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const startButton = qs('#start-search-result');
if (state.status === 'closed') {
sheet.classList.remove('open');
return;
@@ -2474,6 +2683,8 @@
sheet.classList.add('open');
claimButton.hidden = true;
claimButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
if (state.status === 'loading') {
searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
@@ -2503,6 +2714,10 @@
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start';
startButton.disabled = state.status === 'claiming';
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' :
@@ -2517,6 +2732,26 @@
),
onState: renderSearchPreview,
});
const searchAssignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: detail => searchPreview.claim(detail),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
@@ -2618,9 +2853,16 @@
mobileSearchViewport.close();
qs('#open-palette').focus();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
else planTodayPreview.close();
addPlanPreviewOnReturn = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
},
});
taskOverlayHistory.start();
@@ -2718,6 +2960,16 @@
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
try {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.';
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
@@ -2789,6 +3041,26 @@
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
});
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', () => {
@@ -3254,8 +3526,14 @@
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
refreshMyWorkView();
qs('#my-work-action-status').textContent = merging.key + ' merged.';
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
if (workSession.active()) workSession.complete();
if (continuingSession && workSession.active()) {
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
} 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;
@@ -3578,15 +3856,13 @@
});
qs('#save-today-plan').addEventListener('click', () => {
const result = planToday.commit();
if (result === 'saved') closePlanToday();
if (result === 'saved') taskOverlayHistory.leave();
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
});
qs('#save-and-start-today').addEventListener('click', () => {
const result = planToday.commit({ start:true });
if (result === 'saved') {
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
} else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
if (result === 'saved') taskOverlayHistory.leave();
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
});
qs('#start-work-session').addEventListener('click', () => {
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
+16 -2
View File
@@ -196,6 +196,11 @@
</section>
</div>
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
<button id="back-to-plan" type="button">Back to plan</button>
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
</div>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
@@ -218,7 +223,10 @@
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<div class="search-preview-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
@@ -361,6 +369,8 @@
<label for="create-issue-repository">Repository
<select id="create-issue-repository" required></select>
</label>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label for="create-issue-title">Title
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
</label>
@@ -560,7 +570,7 @@
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
@@ -587,6 +597,7 @@
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
@@ -598,12 +609,15 @@
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script>
<script src="static/assign-and-start.js"></script>
<script src="static/queue-today.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/work-route.js"></script>
<script src="static/task-overlay-history.js"></script>
<script src="static/context-poller.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-search-viewport.js"></script>
+16 -3
View File
@@ -7,6 +7,7 @@
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
let workMode = 'work';
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
@@ -34,9 +35,17 @@
refreshVisibility();
}
function updateAttention(count) {
function updateWork(mode, count) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
work: ['Work', 'Work'],
};
workMode = labels[mode] ? mode : 'work';
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
@@ -44,10 +53,14 @@
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
total ? 'Work, ' + total + ' items need attention' : 'Work'
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
);
}
}
return {start, refreshVisibility, updateAttention};
function updateAttention(count) {
updateWork(workMode, count);
}
return {start, refreshVisibility, updateAttention, updateWork};
});
+22
View File
@@ -0,0 +1,22 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileWorkEntry = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
return 'work';
}
function open() {
const current = mode();
if (current === 'continue') options.continueToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else options.openFallback();
return current;
}
return {mode, open};
});
+11
View File
@@ -563,8 +563,19 @@ function createWorkSession({
return {
active: () => running,
checkpointed: () => running && durable,
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (index < 0) return false;
currentIndex = index;
report(items, index);
onOpen(items[index]);
return true;
},
resume() {
const saved = checkpoint?.read();
if (!saved) return false;
+45
View File
@@ -0,0 +1,45 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createPlanTodayPreview = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createPlanTodayPreview({
planner,
identity,
getScroll = () => 0,
setScroll = () => {},
onOpen = () => {},
onClose = () => {},
}) {
let current = null;
function open(item, trigger = null) {
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
current = { item, trigger, scroll:Number(getScroll()) || 0 };
onOpen(item, trigger);
return true;
}
function close({ add = false } = {}) {
if (!current) return 'closed';
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
const id = identity(item);
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
}
current = null;
setScroll(scroll);
onClose(item, trigger);
return result;
}
function snapshot() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
}
return { open, close, snapshot };
};
});
+17
View File
@@ -0,0 +1,17 @@
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
return function queueToday(issue) {
const added = todayWork.add(issue);
if (added !== 'added' && added !== 'exists') return added;
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
refresh();
warm();
return 'sync-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
return 'queued';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
+46 -3
View File
@@ -1,6 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v60';
const CACHE = 'stackchain-dashboard-shell-v67';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -27,6 +28,7 @@ const SHELL = [
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
@@ -38,12 +40,15 @@ const SHELL = [
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
BASE + 'static/queue-today.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-search-viewport.js',
@@ -104,6 +109,41 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
}
async function expiredOfflineResponse() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
return new Response(
'Your Stackchain session expired. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
return cache.match(BASE);
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
@@ -177,6 +217,9 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
@@ -215,13 +258,13 @@ self.addEventListener('fetch', event => {
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
if (cached) return cached;
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
+109 -6
View File
@@ -81,7 +81,9 @@
devicesSheet.hidden = true;
devicesButton?.focus();
});
boundary.resumeQueuedWork();
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
});
};
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
@@ -93,6 +95,9 @@
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
@@ -104,10 +109,38 @@
location.replace(base + 'login?reason=session-revoked');
}
async function handleSessionExpiry() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
onExpired();
location.replace(base + 'login?reason=session-expired');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
}
function storedOfflineLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
async function publishOfflineLease(expiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
scheduleOfflineExpiry(expiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
}
function csrfToken() {
@@ -122,6 +155,56 @@
catch (_error) { return false; }
}
function isDashboardApi(input) {
try {
const url = new URL(String(input?.url || input), origin);
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
} catch (_error) { return false; }
}
async function fetchWithDeadline(input, options, method, phase = 'request') {
if (!isDashboardApi(input)) return fetchImpl(input, options);
const controller = new AbortController();
const callerSignal = options.signal || input?.signal;
let timeout;
let rejectCancellation;
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
const cancelFromCaller = () => {
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
error.source = 'caller';
controller.abort(error);
rejectCancellation(error);
};
if (callerSignal?.aborted) cancelFromCaller();
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
const deadline = new Promise((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(
SAFE_METHODS.has(method)
? 'Request timed out. Try again.'
: 'Request timed out. Refresh to verify the outcome before retrying.'
);
error.name = 'TimeoutError';
error.method = method;
error.outcome = 'unknown';
error.safeToRetry = SAFE_METHODS.has(method);
error.phase = phase;
controller.abort(error);
reject(error);
}, requestTimeoutMs);
});
try {
return await Promise.race([
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
deadline,
cancellation,
]);
} finally {
clearTimeout(timeout);
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
}
}
async function sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
@@ -131,7 +214,7 @@
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
const response = await fetchWithDeadline(input, requestOptions, method);
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
const payload = await response.clone().json().catch(() => ({}));
const detail = payload?.detail || {};
@@ -147,7 +230,7 @@
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
@@ -155,7 +238,7 @@
action: detail.action,
target: detail.target,
}),
});
}, 'POST', 'fresh-authorization');
if (!authorization.ok) return authorization;
const grant = await authorization.json().catch(() => ({}));
if (!grant.grant) return response;
@@ -171,13 +254,32 @@
expirationStarted = false;
await handleRemoteRevocation();
} else {
onExpired();
location.replace(base + 'login?reason=session-expired');
expirationStarted = false;
await handleSessionExpiry();
}
}
return response;
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
await publishOfflineLease(payload.expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) {
scheduleOfflineExpiry(expiresAt);
return true;
}
if (expiresAt) await handleSessionExpiry();
return false;
}
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
@@ -287,6 +389,7 @@
revokeActiveDevice,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
};
});
+1 -1
View File
@@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview']);
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
+45
View File
@@ -0,0 +1,45 @@
from starlette.datastructures import Headers
from starlette.middleware.gzip import GZipResponder, IdentityResponder
from starlette.types import ASGIApp, Receive, Scope, Send
def _quality(accept_encoding: str, coding: str) -> float:
qualities: dict[str, float] = {}
for entry in accept_encoding.split(","):
parts = [part.strip() for part in entry.split(";")]
name = parts[0].lower()
if not name:
continue
quality = 1.0
for parameter in parts[1:]:
key, separator, value = parameter.partition("=")
if separator and key.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 0.0
qualities[name] = quality
return qualities.get(coding, qualities.get("*", 0.0))
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
if _quality(accepted, "gzip") > 0:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
else:
responder = IdentityResponder(self.app, self.minimum_size)
await responder(scope, receive, send)
+50 -2
View File
@@ -133,6 +133,14 @@ class WorkItems(list[dict]):
self.pagination = pagination
class RepositoryItems(list[dict]):
"""A list-compatible repository page carrying truthful pagination metadata."""
def __init__(self, items: list[dict], pagination: dict):
super().__init__(items)
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
@@ -261,8 +269,48 @@ async def current_user() -> dict:
return await fetch("user")
async def repos() -> list[dict]:
return await fetch("user/repos?limit=50")
async def repo_page(page: int = 1, limit: int = 50) -> dict:
"""Load one bounded page of repositories available to the current user."""
response = await _get_client().get(
"/api/v1/user/repos",
headers=_auth(),
params={"page": page, "limit": limit},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea repository response was not a list")
items = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
async def repos() -> RepositoryItems:
result = await repo_page()
return RepositoryItems(
result["items"],
{key: result[key] for key in ("page", "total", "has_more")},
)
async def repository_access(repository: str) -> dict | None:
"""Return a repository only when the authenticated user can access it."""
response = await _get_client().get(
f"/api/v1/repos/{repository}", headers=_auth()
)
if response.status_code == 404:
return None
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else None
WORK_SEARCHES = {
+44 -23
View File
@@ -19,6 +19,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import dashboard_auth, gitea_proxy
from src.compression import NegotiatedGZipMiddleware
from src.gitea_proxy import (
activity_events,
current_user,
@@ -67,6 +68,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
@@ -502,6 +504,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
]
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
repository_pagination = getattr(repo_data, "pagination", None)
if isinstance(repository_pagination, dict):
payload["repository_pagination"] = repository_pagination
pagination = {}
pagination.update(getattr(issues_data, "pagination", {}))
pagination.update(getattr(prs_data, "pagination", {}))
@@ -875,10 +880,13 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request):
@app.get("/api/v1/session")
async def session_status(request: Request):
session = request.state.dashboard_session
return {
payload = {
"authenticated": session is not None,
"csrf_token": session.csrf if session is not None else "",
}
if session is not None:
payload["expires_at"] = session.expires_at
return payload
def _today_store() -> TodayStore:
@@ -1210,6 +1218,36 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/repositories")
async def repository_page(
page: int = Query(default=1, ge=1, le=1000),
limit: int = Query(default=50, ge=1, le=50),
) -> JSONResponse:
"""Return one bounded repository page for lazy issue-capture selection."""
try:
result = await asyncio.wait_for(
gitea_proxy.repo_page(page=page, limit=limit),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
items = [
Repo(
id=item["id"], name=item["name"], full_name=item["full_name"],
description=item.get("description") or "", url=item["html_url"],
updated_at=item.get("updated_at", ""),
).model_dump()
for item in result.get("items", [])
if isinstance(item, dict)
and all(field in item for field in ("id", "name", "full_name", "html_url"))
]
return JSONResponse({**result, "items": items})
except Exception:
return JSONResponse(
{"error": "Repositories could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/background-identity")
async def background_identity() -> JSONResponse:
"""Return only the account key required to safely drain a browser outbox."""
@@ -2188,13 +2226,7 @@ async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_milestones():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_milestones(repository)
@@ -2217,13 +2249,7 @@ async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_labels():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_labels(repository)
@@ -2249,16 +2275,11 @@ async def create_assigned_issue(
repository = f"{owner}/{repo}"
async def create_issue():
user, available = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repos()
user, accessible = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repository_access(repository)
)
login = user.get("login") if isinstance(user, dict) else None
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if not login or repository not in accessible:
if not login or accessible is None:
raise HTTPException(status_code=404, detail="Repository not found")
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)
+276
View File
@@ -0,0 +1,276 @@
import json
import subprocess
from pathlib import Path
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
script = f"""
const fs=require('fs');
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
let release;
const claimResult=new Promise(resolve=>{{release=resolve;}});
const flow=createAssignAndStart({{
available:()=>true,
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const issue={{repository:'stackchain/dashboard',number:411}};
const first=flow.run(issue);
const second=flow.run(issue);
release({{...issue,assignees:['timmy']}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
}}
"""
assert run_node(script) == {
"available": True,
"calls": [
"claim:411",
"start:411",
"announce:Assigned, added to Today, and ready to work.",
],
"results": ["started", "started"],
"same": True,
}
def test_full_today_blocks_before_assignment_and_explains_recovery():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const flow=createAssignAndStart({{
available:()=>false,
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
start:()=>calls.push('start'),
recover:()=>calls.push('recover'),
announce:message=>calls.push('announce:'+message),
}});
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "full",
"calls": ["announce:Today is full—remove an item before assigning this issue."],
}
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>Promise.resolve(issue),
start:()=> 'sync-unavailable',
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": "recovery",
"calls": [
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
"recover:411",
],
}
def test_failed_assignment_does_not_start_today_and_can_be_retried():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];let attempt=0;
const issue={{repository:'stackchain/dashboard',number:411}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
start:()=>{{calls.push('start');return 'started';}},
recover:()=>calls.push('recover'),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue).catch(error=>{{
calls.push('error:'+error.message);
return flow.run(issue);
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": "started",
"calls": [
"claim:1",
"error:conflict",
"claim:2",
"start",
"announce:Assigned, added to Today, and ready to work.",
],
}
def test_already_owned_search_result_starts_without_claiming():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue, {{alreadyOwned:true}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "started",
"calls": [
"start:413",
"announce:Added to Today and ready to work.",
],
}
def test_queue_today_claims_once_without_starting_the_active_session():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
let release;
const claimResult=new Promise(resolve=>{{release=resolve;}});
const issue={{repository:'stackchain/dashboard',number:415}};
const flow=createAssignAndStart({{
available:()=>true,
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const first=flow.run(issue, {{destination:'queue'}});
const second=flow.run(issue, {{destination:'queue'}});
release({{...issue,assignees:['timmy']}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
calls,results,same:first===second
}})));
"""
assert run_node(script) == {
"calls": [
"claim:415",
"queue:415",
"announce:Assigned and queued in Today. Keep finding work when ready.",
],
"results": ["queued", "queued"],
"same": True,
}
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
script = f"""
const fs=require('fs');
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
const calls=[];
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
const queue=createQueueToday({{
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'),
warm:()=>calls.push('warm'),
}});
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
}}
"""
assert run_node(script) == {
"available": True,
"result": "queued",
"calls": [
"add",
"add:issue:stackchain/dashboard:415:",
"refresh",
"flush",
"warm",
],
}
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>Promise.resolve(issue),
start:()=> 'started',
queue:()=> 'sync-unavailable',
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue, {{destination:'queue'}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "recovery",
"calls": [
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
"recover:415",
],
}
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert "data-claim-start-index" in dashboard
assert "data-claim-queue-index" in dashboard
assert ">Start now</button>" in dashboard
assert ">Queue Today</button>" in dashboard
assert "const assignAndStart = createAssignAndStart({" in dashboard
assert "assignAndStart.run(item)" in dashboard
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
assert "queue: confirmed =>" in dashboard
assert "const queueToday = createQueueToday({" in dashboard
assert "return queueToday(claimed)" in dashboard
assert "createAndStart.available" in dashboard
assert "createAndStart.complete" in dashboard
assert ".find-work-claim-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
assert ".find-work-claim-actions [data-claim-start-index]" in css
assert "grid-column:1 / -1" in css
assert "BASE + 'static/assign-and-start.js'" in worker
assert "BASE + 'static/queue-today.js'" in worker
assert '<script src="static/queue-today.js"></script>' in html
+12
View File
@@ -243,6 +243,18 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
assert "openPreviewIssueInMyWork" in html
def test_search_preview_offers_assign_and_start_for_eligible_issues():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="start-search-result"' in html
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
assert "const searchAssignAndStart = createAssignAndStart({" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
script = f"""
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
+22 -6
View File
@@ -254,12 +254,16 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json()["authenticated"] is True
payload = response.json()
assert payload["authenticated"] is True
assert isinstance(payload["expires_at"], int)
assert payload["expires_at"] > int(time.time())
assert response.headers["cache-control"] == "no-store"
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
@@ -267,14 +271,26 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
)
response = await client.get("/api/v1/session")
payload = response.json()
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/session")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert "expires_at" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
+223 -5
View File
@@ -14,11 +14,13 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
getItem(key) {{ return this.values.get(key) || null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
@@ -27,6 +29,7 @@ const boundary = createSessionBoundary({{
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
if (state.failFetch) throw new Error('offline');
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
}},
@@ -53,6 +56,8 @@ const boundary = createSessionBoundary({{
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}});
@@ -62,6 +67,160 @@ const boundary = createSessionBoundary({{
return json.loads(result.stdout)
def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
let aborted = false;
const boundary = createSessionBoundary({{
cookie: () => '',
origin: 'https://forge.example',
base: '/dashboard/',
requestTimeoutMs: 10,
fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{
options.signal?.addEventListener('abort', () => {{ aborted = true; }});
setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80);
}}),
location: {{ replace: () => {{}} }},
}});
(async () => {{
const started = Date.now();
try {{ await boundary.fetch('/dashboard/api/v1/context'); }}
catch (error) {{
process.stdout.write(JSON.stringify({{
name:error.name, message:error.message, method:error.method,
outcome:error.outcome, aborted, elapsed:Date.now()-started,
}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output["name"] == "TimeoutError"
assert output["message"] == "Request timed out. Try again."
assert output["method"] == "GET"
assert output["outcome"] == "unknown"
assert output["aborted"] is True
assert output["elapsed"] < 70
def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const caller = new AbortController();
let requestAborted = false;
const boundary = createSessionBoundary({{
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
fetchImpl: (_url, options = {{}}) => new Promise(() => {{
options.signal.addEventListener('abort', () => {{ requestAborted = true; }});
}}),
location: {{ replace: () => {{}} }},
}});
(async () => {{
const started = Date.now();
const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}});
setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10);
try {{ await pending; }} catch (error) {{
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output["name"] == "AbortError"
assert output["message"] == "Superseded"
assert output["source"] == "caller"
assert output["requestAborted"] is True
assert output["elapsed"] < 100
def test_request_object_abort_signal_is_composed_with_the_api_deadline():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const caller = new AbortController();
const boundary = createSessionBoundary({{
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
}});
(async () => {{
const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}});
const pending = boundary.fetch(request);
setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5);
try {{ await pending; }} catch (error) {{
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
assert json.loads(result.stdout) == {
"name": "AbortError",
"message": "Request superseded",
"source": "caller",
}
def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const boundary = createSessionBoundary({{
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
}});
(async () => {{
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }}
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output == {
"name": "TimeoutError",
"message": "Request timed out. Refresh to verify the outcome before retrying.",
"method": "PATCH",
"safeToRetry": False,
}
def test_fresh_authorization_request_uses_the_same_deadline_boundary():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
let calls = 0;
const boundary = createSessionBoundary({{
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
fetchImpl: () => {{
calls += 1;
if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}}));
return new Promise(() => {{}});
}},
promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}},
}});
(async () => {{
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }}
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
assert json.loads(result.stdout) == {
"name": "TimeoutError",
"phase": "fresh-authorization",
"calls": 2,
}
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
@@ -73,6 +232,46 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
]
assert result["leaseDelay"] > 0
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '1');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario(
"""
@@ -106,7 +305,7 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][2]["body"] == result["requests"][0]["body"]
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
result = run_session_scenario(
"""
state.responseStatus = 401;
@@ -115,6 +314,7 @@ await Promise.all([
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
@@ -122,9 +322,12 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True
assert result["replacedAfterDeletion"] is True
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
@@ -160,6 +363,21 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
+1 -1
View File
@@ -195,7 +195,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html
+58
View File
@@ -3,8 +3,10 @@ import re
import shutil
from pathlib import Path
import httpx
import pytest
from src import main
from src.frontend_bundle import build_frontend
from src.views import dashboard, runtime_bundle, service_worker
@@ -37,6 +39,62 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert changed.runtime_bytes != first.runtime_bytes
@pytest.mark.anyio
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert int(response.headers["content-length"]) <= 100 * 1024
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": accept_encoding},
)
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
@pytest.mark.anyio
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
stylesheet = await client.get(
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
)
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
assert page.headers["content-encoding"] == "gzip"
assert page.headers["cache-control"] == "no-cache"
assert page.headers["content-security-policy"]
assert main.FRONTEND_BUILD.runtime_name in page.text
assert stylesheet.headers["content-encoding"] == "gzip"
assert "mobile-task-dock" in stylesheet.text
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
assert "content-encoding" not in health.headers
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
build = build_frontend(FRONTEND)
+159
View File
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
async def repository_access_from(loader, repository):
repositories = await loader()
return next(
(item for item in repositories if item.get("full_name") == repository), None
)
@pytest.mark.anyio
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
requests = []
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
}
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -373,6 +388,118 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.url.path, dict(request.url.params)))
if request.url.path.endswith("/user/repos"):
return httpx.Response(
200,
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
headers={"X-Total-Count": "51"},
)
if request.url.path.endswith("/repos/stackchain/later"):
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
page = await gitea_proxy.repo_page(page=2, limit=50)
first_page = await gitea_proxy.repos()
accessible = await gitea_proxy.repository_access("stackchain/later")
missing = await gitea_proxy.repository_access("stackchain/missing")
finally:
await gitea_proxy.stop_client()
assert page == {
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
"page": 2,
"total": 51,
"has_more": False,
}
assert accessible["full_name"] == "stackchain/later"
assert missing is None
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
assert calls == [
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
("/api/v1/repos/stackchain/later", {}),
("/api/v1/repos/stackchain/missing", {}),
]
@pytest.mark.anyio
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
access_calls = []
create_calls = []
async def page(page, limit):
assert (page, limit) == (2, 50)
return {
"items": [{
"id": 51, "name": "later", "full_name": "stackchain/later",
"description": "", "html_url": "https://forge.example/stackchain/later",
"updated_at": "2026-08-09T12:00:00Z",
}],
"page": 2, "total": 51, "has_more": False,
}
async def access(repository):
access_calls.append(repository)
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
async def user():
return {"login": "timmy"}
async def create(repository, title, body, assignee, label_ids):
create_calls.append((repository, title, assignee))
return {
"id": 403, "number": 403, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-09T12:00:00Z",
"url": "https://forge.example/stackchain/later/issues/403",
}
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repositories?page=2&limit=50")
created = await client.post(
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
)
assert listed.status_code == 200
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
assert listed.json()["has_more"] is False
assert created.status_code == 201
assert access_calls == ["stackchain/later"]
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
def test_context_exposes_truthful_repository_pagination():
repositories = gitea_proxy.RepositoryItems(
[{
"id": 1, "name": "api", "full_name": "stackchain/api",
"description": "", "html_url": "https://forge.example/stackchain/api",
"updated_at": "2026-08-09T12:00:00Z",
}],
{"page": 1, "total": 51, "has_more": True},
)
payload = main._context_payload(
{"id": 2, "login": "timmy"}, repositories, [], []
)
assert payload["repository_pagination"] == {
"page": 1, "total": 51, "has_more": True
}
@pytest.mark.anyio
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
calls = []
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-concurrent"}
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-create-201"}
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v60" in worker
assert "stackchain-dashboard-shell-v67" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v60" in worker
assert "stackchain-dashboard-shell-v67" in worker
+91 -3
View File
@@ -8,6 +8,39 @@ from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const state = {{active:true, resumable:true, today:2}};
const calls = [];
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => state.resumable,
getTodayCount: () => state.today,
continueToday: () => calls.push('continue'),
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
openFallback: () => calls.push('fallback'),
}});
const modes = [];
modes.push(entry.open());
state.active = false; modes.push(entry.open());
state.resumable = false; modes.push(entry.open());
state.today = 0; modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "resume", "start", "work"],
"calls": ["continue", "resume", "start", "fallback"],
}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
@@ -70,6 +103,43 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const workLabel = {{ textContent:'' }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
dock.updateWork('continue', 3);
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateWork('resume', 0);
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateWork('start', 0);
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('work', 0);
process.stdout.write(JSON.stringify({{
continuing, resuming, starting,
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"continuing": {
"text": "Continue",
"count": "3",
"hidden": False,
"label": "Continue Today, 3 items need attention",
},
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"fallback": {"text": "Work", "label": "Work"},
}
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -95,6 +165,24 @@ process.stdout.write(JSON.stringify({{
}
@pytest.mark.anyio
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
html = await dashboard()
assert 'id="mobile-work-label">Work</span>' in html
assert '<script src="static/mobile-work-entry.js"></script>' in html
assert "const mobileWorkEntry = createMobileWorkEntry({" in html
assert "isTodayActive: () => workSession.checkpointed()" in html
assert "isTodayResumable: () => workSession.resumable()" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "continueToday: continueTodaySession" in html
assert "resumeToday: resumeTodaySession" in html
assert "startToday: startTodaySession" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
assert "workSession.reopen()" in html
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
@@ -112,12 +200,12 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: openMobileWork" in html
assert "counts.attention ? 'attention' : 'all'" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "const filter = counts.attention ? 'attention' : 'all';" in html
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
assert "draftCount.textContent = sourceDraftCount.textContent" in html
assert "mobileTaskDock.updateAttention(counts.attention)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
+152
View File
@@ -1428,6 +1428,49 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
assert json.loads(result.stdout) == {"finished": 1, "active": False}
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let saves = 0;
const checkpoint = {{
read: () => null,
save: () => {{ saves += 1; return true; }},
clear: () => true,
}};
const items = [1, 2].map(number => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const progress = [];
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all', checkpoint,
onOpen: item => opened.push(item.number),
onProgress: state => progress.push(state.index), onFinish: () => {{}},
}});
session.start();
session.next();
const savesBeforeReopen = saves;
const reopened = session.reopen();
process.stdout.write(JSON.stringify({{
reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
todayActive:session.checkpointed(),
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"reopened": True,
"opened": [1, 2, 2],
"progress": [1, 2, 2],
"savesBeforeReopen": 2,
"savesAfterReopen": 2,
"todayActive": True,
}
def test_today_session_checkpoint_resumes_current_item_after_reload():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -1643,6 +1686,56 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_
)
@pytest.mark.anyio
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
html = await dashboard()
assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
assert "const continuingSession = workSession.active();" in merge_handler
assert "if (workSession.active()) workSession.complete();" in merge_handler
assert "merged. Next work item opened." in merge_handler
assert merge_handler.index("await pullController.merge") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
)
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
)
@pytest.mark.anyio
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
html = await dashboard()
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
assert "else if (!continuingSession)" in merge_handler
assert "merging.key + ' merged.'" in merge_handler
assert "else if (continuingSession" not in merge_handler
assert "onFinish: () =>" in html
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
@pytest.mark.anyio
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
html = await dashboard()
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
assert "lastMyWork = lastMyWork.filter" in success_path
assert "workSession.complete()" in success_path
assert "lastMyWork = lastMyWork.filter" not in failure_path
assert "workSession.complete()" not in failure_path
assert "The pull request remains in My Work; refresh and retry." in failure_path
assert "button.disabled = false;" in failure_path
def test_work_session_can_start_at_a_newly_created_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -1938,6 +2031,19 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
assert '.load-more-work { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
html = await dashboard()
assert 'id="load-more-issue-repositories"' in html
assert '>Load more repositories<' in html
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
assert 'appendIssueRepositories(payload.items)' in html
assert '.create-issue-repository-more { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()
@@ -2040,6 +2146,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"][1]["number"] == 17
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = 0;
let release;
const capture = createIssueCapture({{
storage,
fetchJson: url => {{
calls += 1;
if (calls === 1) return Promise.reject(new Error('temporary'));
return new Promise(resolve => {{ release = () => resolve({{
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
}}); }});
}},
}});
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
capture.loadRepositoryPage(2).catch(() => {{
const first = capture.loadRepositoryPage(2);
const duplicate = capture.loadRepositoryPage(2);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, results, draft:capture.loadDraft()
}})));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 2
assert output["results"] == [
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
]
assert output["draft"] == {
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
}
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+69 -1
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
PLAN_TODAY_PREVIEW = Path(__file__).parents[1] / "frontend" / "plan-today-preview.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
@@ -78,6 +79,53 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
}
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const events = [];
let scroll = 318;
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
planner.open([item(1)], [item(1), item(2)]);
const preview = createPlanTodayPreview({{
planner,
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
getScroll: () => scroll,
setScroll: value => {{ scroll = value; }},
onOpen: value => events.push(['open', value.number]),
onClose: (value, trigger) => events.push(['close', value.number, trigger]),
}});
preview.open(item(2), 'preview-2');
scroll = 0;
const first = preview.close({{add:true}});
preview.open(item(2), 'preview-2');
scroll = 0;
const second = preview.close({{add:true}});
process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:planner.snapshot()}}));
"""
assert run_node(script) == {
"first": "added",
"second": "already-added",
"scroll": 318,
"events": [
["open", 2],
["close", 2, "preview-2"],
["open", 2],
["close", 2, "preview-2"],
],
"planner": {
"open": True,
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
"count": 2,
"limit": 5,
},
}
@pytest.mark.anyio
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
html = await dashboard()
@@ -96,10 +144,30 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
assert ".plan-today-item { grid-template-columns:1fr; }" in html
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
assert ".plan-preview-actions { position:fixed; z-index:76;" in html
assert '<script src="static/plan-today-preview.js"></script>' in html
assert 'data-plan-preview="' in html
assert 'id="add-plan-preview"' in html
assert "taskOverlayHistory.open('plan-today-preview')" in html
assert "planTodayPreview.close({ add:true })" in html
assert "Preview unavailable offline" in html
@pytest.mark.anyio
async def test_plan_today_wires_cancel_back_and_success_through_overlay_history():
html = await dashboard()
assert "taskOverlayHistory.open('plan-today')" in html
assert "previous === 'plan-today' && kind !== 'plan-today'" in html
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
assert "openPlanToday(planTodayTrigger, false)" in html
assert "closePlanToday(false)" in html
assert "if (result === 'saved') taskOverlayHistory.leave();" in html
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+57 -9
View File
@@ -14,10 +14,23 @@ const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
return state.cachedBody === null ? null : new Response(state.cachedBody);
}},
put: async (request, response) => {{
const key = String(request.url || request);
state.puts.push(key);
storedResponses.set(key, response.clone());
}},
}};
const context = {{
URL, Request, Response, Headers, AbortController,
@@ -108,7 +121,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -117,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -125,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -141,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -318,6 +331,13 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
assert "batch: withSessionCsrf" in source
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/queue-today.js'" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
@@ -352,6 +372,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
@@ -363,12 +384,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
"/dashboard/static/assign-and-start.js",
"/dashboard/static/queue-today.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/task-overlay-history.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-search-viewport.js",
@@ -408,6 +432,30 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
state.failFetch = true;
state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', {
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({
status: response.status,
body: await response.text(),
state,
}));
"""
)
assert result["status"] == 401
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
+83
View File
@@ -117,6 +117,89 @@ process.stdout.write(JSON.stringify({{left, state:history.state, current:control
}
def test_plan_today_uses_one_history_layer_and_can_leave_after_save():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const changes = [];
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const history = {{
get state() {{ return stack[cursor]; }},
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
replaceState(state) {{ stack[cursor] = state; }},
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
history,
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
const opened = controller.open('plan-today');
const repeated = controller.open('plan-today');
const left = controller.leave();
process.stdout.write(JSON.stringify({{
opened, repeated, left, stack, cursor, current:controller.current(), changes,
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": True,
"repeated": True,
"left": True,
"stack": [
{"page": "dashboard"},
{"page": "dashboard"},
],
"cursor": 1,
"current": None,
"changes": [["plan-today", None], [None, "plan-today"]],
}
def test_plan_today_preview_is_a_nested_browser_history_layer():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const changes = [];
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const history = {{
get state() {{ return stack[cursor]; }},
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
history,
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
controller.open('plan-today');
const previewed = controller.open('plan-today-preview');
controller.close();
process.stdout.write(JSON.stringify({{previewed, cursor, current:controller.current(), changes}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"previewed": True,
"cursor": 1,
"current": "plan-today",
"changes": [
["plan-today", None],
["plan-today-preview", "plan-today"],
["plan-today", "plan-today-preview"],
],
}
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
html = dashboard_bundle_text()
+1 -1
View File
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/today-sync.js'" in source
+2 -1
View File
@@ -161,7 +161,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" in html
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "startToday: startTodaySession" in html
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
assert 'data-today-add' in html
assert 'data-today-remove' in html